symbiot 0.58.2 → 0.59.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 +23 -0
- package/LICENSE +98 -21
- package/README.md +28 -0
- package/agents.mjs +89 -19
- package/claudesetup.mjs +4 -1
- package/core.mjs +29 -3
- package/guard.mjs +12 -4
- package/headless.mjs +105 -9
- package/home.mjs +8 -6
- package/index.mjs +60 -7
- package/lanes.mjs +9 -5
- package/licence.mjs +144 -0
- package/links.mjs +7 -0
- package/marketing.mjs +86 -11
- package/mind.mjs +96 -20
- package/package.json +6 -4
- package/post.mjs +4 -2
- package/server.mjs +26 -7
- package/tasks.mjs +3 -0
- package/tray.mjs +154 -0
- package/ui.mjs +180 -33
- package/watch.mjs +57 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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.59.0
|
|
6
|
+
|
|
7
|
+
- **Symbiot Free and Symbiot Pro.** Every install starts with 14 days of Pro. After that, Free keeps the orb, Home, Talk to Symbiot, your week and standups, voice and the Away screen, with one agent at a time, up to 3 projects and one connected inbox or chat. Pro (US$12 a month or US$99 a year) adds any number of agents at once, every project, every inbox and chat, the marketing lane and Approve-and-ship. **Settings → Symbiot Pro** shows where you stand, takes a key and lets you swap one of Free's 3 projects. A limit never stops anything running: the next thing just doesn't start, with a line saying why. Keys are checked offline (signed by Ghost AI; cancelled keys are listed on symbiot.co.za).
|
|
8
|
+
- **Symbiot doesn't work on its own code.** No agent runs in Symbiot's repo or a fork of it, and the guard stops agents editing Symbiot's installed files or its licence files.
|
|
9
|
+
- **Elastic License 2.0** from this version on (0.58.2 and earlier stay MIT).
|
|
10
|
+
- **Tests never sign in or install for real.** The test that presses every button was starting a real Claude sign-in in the browser and running Anthropic's installer; both are now switched off in tests (`SYMBIOT_NO_SIGNIN`).
|
|
11
|
+
## 0.58.3 — 2026-10-09
|
|
12
|
+
|
|
13
|
+
- Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
|
|
14
|
+
- Fixed a stuck urgent hold that kept projects paused for hours; it now clears about two minutes after nothing starts running.
|
|
15
|
+
- Changed agents so they no longer start in a folder with no open tasks or new answers, which avoids wasted sessions.
|
|
16
|
+
- Fixed Symbiot saying an agent was "already on urgent work" when nothing was running.
|
|
17
|
+
- Added a "posted" status for marketing drafts so a posted draft leaves the Marketing orb and can't be posted twice.
|
|
18
|
+
- Added `symbiot screens upload` to attach a picture or video to a page's file field, such as a LinkedIn post.
|
|
19
|
+
- Added a fact-only brand brief for the designer making the Symbiot icon and logo.
|
|
20
|
+
- Changed the LinkedIn reader to capture who sent each message and what each notification is about, not just badge counts.
|
|
21
|
+
- Added a review of the mobile companion spec, with follow-up tasks and open decisions.
|
|
22
|
+
- Added a "superseded" status so a redone marketing post replaces the old one on the Marketing orb.
|
|
23
|
+
- Added the pick tray to the Marketing page, showing captures with switchable blur boxes and one-click media selection.
|
|
24
|
+
- Fixed Home cards erasing what you were typing in "answer in your own words" whenever the page updated.
|
|
25
|
+
- Fixed tall question cards on Home overlapping the working-agents row below them.
|
|
26
|
+
- Changed where Symbiot keeps your AI key and app token so agent runs can no longer read them.
|
|
27
|
+
|
|
5
28
|
## 0.58.2
|
|
6
29
|
|
|
7
30
|
- **A voice that doesn't sound robotic, picked for you.** When Symbiot reads a reply out, it uses your computer's own voice only when that's a modern, natural one (Edge's Natural voices on Windows, Apple's Premium and Enhanced voices, a phone's own). Otherwise, as on Linux, where Chrome only has its older Google voices and espeak, Symbiot speaks with its own voice: Piper, a small open-source speech engine that runs on your computer. The accent follows where you are (British English for South Africa, the UK, Australia and the rest; American for the US and Canada), and both voices are public domain. Nothing to choose, no account, nothing leaves your computer. It downloads once (about 85 MB) the first time it's needed, while the system voice covers that first reply, and then answers sentence by sentence, the first in under a second.
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,98 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
Copyright (c) 2026 Garth White (Ghost AI)
|
|
2
|
+
|
|
3
|
+
Symbiot is licensed under the Elastic License 2.0, below. Versions up to and
|
|
4
|
+
including 0.58.2 were released under the MIT License and stay under it.
|
|
5
|
+
|
|
6
|
+
Elastic License 2.0
|
|
7
|
+
|
|
8
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
9
|
+
|
|
10
|
+
## Acceptance
|
|
11
|
+
|
|
12
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
13
|
+
|
|
14
|
+
## Copyright License
|
|
15
|
+
|
|
16
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
17
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
18
|
+
available, and prepare derivative works of the software, in each case subject to
|
|
19
|
+
the limitations and conditions below.
|
|
20
|
+
|
|
21
|
+
## Limitations
|
|
22
|
+
|
|
23
|
+
You may not provide the software to third parties as a hosted or managed
|
|
24
|
+
service, where the service provides users with access to any substantial set of
|
|
25
|
+
the features or functionality of the software.
|
|
26
|
+
|
|
27
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
28
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
29
|
+
software that is protected by the license key.
|
|
30
|
+
|
|
31
|
+
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
|
32
|
+
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
|
33
|
+
to applicable law.
|
|
34
|
+
|
|
35
|
+
## Patents
|
|
36
|
+
|
|
37
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
38
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
39
|
+
sale, import and have imported the software, in each case subject to the
|
|
40
|
+
limitations and conditions in this license. This license does not cover any
|
|
41
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
42
|
+
the software. If you or your company make any written claim that the software
|
|
43
|
+
infringes or contributes to infringement of any patent, your patent license for
|
|
44
|
+
the software granted under these terms ends immediately. If your company makes
|
|
45
|
+
such a claim, your patent license ends immediately for work on behalf of your
|
|
46
|
+
company.
|
|
47
|
+
|
|
48
|
+
## Notices
|
|
49
|
+
|
|
50
|
+
You must ensure that anyone who gets a copy of any part of the software from you
|
|
51
|
+
also gets a copy of these terms.
|
|
52
|
+
|
|
53
|
+
If you modify the software, you must include in any modified copies of the
|
|
54
|
+
software prominent notices stating that you have modified the software.
|
|
55
|
+
|
|
56
|
+
## No Other Rights
|
|
57
|
+
|
|
58
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
59
|
+
these terms.
|
|
60
|
+
|
|
61
|
+
## Termination
|
|
62
|
+
|
|
63
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
64
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
65
|
+
with a notice of your violation, and you cease all violation of this license no
|
|
66
|
+
later than 30 days after you receive that notice, your licenses will be
|
|
67
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
68
|
+
reinstatement, any additional violation of these terms will cause your licenses
|
|
69
|
+
to terminate automatically and permanently.
|
|
70
|
+
|
|
71
|
+
## No Liability
|
|
72
|
+
|
|
73
|
+
*As far as the law allows, the software comes as is, without any warranty or
|
|
74
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
75
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
76
|
+
legal claim.*
|
|
77
|
+
|
|
78
|
+
## Definitions
|
|
79
|
+
|
|
80
|
+
The **licensor** is the entity offering these terms, and the **software** is the
|
|
81
|
+
software the licensor makes available under these terms, including any portion
|
|
82
|
+
of it.
|
|
83
|
+
|
|
84
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
85
|
+
|
|
86
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
87
|
+
organization that you work for, plus all organizations that have control over,
|
|
88
|
+
are under the control of, or are under common control with that
|
|
89
|
+
organization. **control** means ownership of substantially all the assets of an
|
|
90
|
+
entity, or the power to direct its management and policies by vote, contract, or
|
|
91
|
+
otherwise. Control can be direct or indirect.
|
|
92
|
+
|
|
93
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
94
|
+
these terms.
|
|
95
|
+
|
|
96
|
+
**use** means anything you do with the software requiring one of your licenses.
|
|
97
|
+
|
|
98
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/README.md
CHANGED
|
@@ -1250,6 +1250,8 @@ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a
|
|
|
1250
1250
|
# (--yes isn't needed on a site under Trusted sites in Settings)
|
|
1251
1251
|
symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
|
|
1252
1252
|
symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
|
|
1253
|
+
symbiot screens upload <id> "Add media" ~/clip.mp4 --yes # put a file in the page's file box: the field,
|
|
1254
|
+
# or the button a site hides it behind ("used" says which box)
|
|
1253
1255
|
symbiot screens scroll <id> # "more": "below"? scroll down and map the next part (or up, top, bottom)
|
|
1254
1256
|
symbiot screens whole <id> # all of that page in one tall screen (or: symbiot screens map <site> --whole)
|
|
1255
1257
|
symbiot screens show <id> # a saved screen's blueprint
|
|
@@ -1417,6 +1419,32 @@ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, b
|
|
|
1417
1419
|
it can't read the scale on Wayland, and ydotool's moves follow your pointer
|
|
1418
1420
|
acceleration, so a click there can land off target.
|
|
1419
1421
|
|
|
1422
|
+
## Symbiot Free and Pro
|
|
1423
|
+
|
|
1424
|
+
Every install starts with **14 days of Pro**, no key and no card. After that Symbiot
|
|
1425
|
+
carries on as **Free**:
|
|
1426
|
+
|
|
1427
|
+
- the orb, Home and Talk to Symbiot (typed or spoken), your week, your standup and
|
|
1428
|
+
what's on your plate, the Away screen;
|
|
1429
|
+
- **one agent at a time**, in **up to 3 projects** (the first 3 it works in; Settings →
|
|
1430
|
+
Symbiot Pro can free a slot for another);
|
|
1431
|
+
- **one connected inbox or chat**.
|
|
1432
|
+
|
|
1433
|
+
**Pro** (US$12 a month or US$99 a year) adds any number of agents at once, every project,
|
|
1434
|
+
every inbox and chat (all your mail, WhatsApp, LinkedIn, calendars), the marketing lane
|
|
1435
|
+
and Approve-and-ship. Get it from [symbiot.co.za](https://symbiot.co.za/#pro) and paste
|
|
1436
|
+
the key you're sent into **Settings → Symbiot Pro**. A limit never stops anything already
|
|
1437
|
+
running: it only keeps the next thing from starting, and says why.
|
|
1438
|
+
|
|
1439
|
+
Keys are checked on your computer (they're signed by Ghost AI; there's no account and no
|
|
1440
|
+
server). Symbiot never works on its own code (its repo, a fork of it, or its installed
|
|
1441
|
+
files), so it can't be asked to take out its own limits.
|
|
1442
|
+
|
|
1443
|
+
**Licence.** From 0.59.0 Symbiot is under the [Elastic License 2.0](LICENSE): free to use,
|
|
1444
|
+
change and share, but not to get round the licence key, sell it as a hosted service, or
|
|
1445
|
+
remove its notices. Versions up to and including 0.58.2 were released under MIT and stay
|
|
1446
|
+
under it.
|
|
1447
|
+
|
|
1420
1448
|
## Config, development and releasing
|
|
1421
1449
|
|
|
1422
1450
|
Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
|
package/agents.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Handing a repo to the user's agent: the saved command template, the one-click
|
|
2
2
|
// presets (and the Orca IDE one), the background-job registry behind the Agents
|
|
3
3
|
// tab, and the questions an unattended agent leaves for the user.
|
|
4
|
+
import { MARKETING_DIR } from "./marketing.mjs";
|
|
5
|
+
import { licenceState, isPro, can, canWorkIn } from "./licence.mjs";
|
|
4
6
|
import { spawn } from "node:child_process";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { join, dirname, resolve, relative } from "node:path";
|
|
@@ -190,7 +192,7 @@ function withTrust(tmpl, box = null) {
|
|
|
190
192
|
// config.agentSandbox: false turns it off.
|
|
191
193
|
const SANDBOX_DIR = join(CONFIG_DIR, "sandbox");
|
|
192
194
|
const SANDBOX_CACHES = [".npm", ".cache"]; // npm's cache and logs; pip, uv, node-gyp, Chrome for Testing
|
|
193
|
-
const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json"]; // your AI's key and the app's token too
|
|
195
|
+
const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json", ".config/symbiot/secrets.json"]; // your AI's key and the app's token too
|
|
194
196
|
// Local sockets are allowed (headless Chrome needs one to start, so the browser tests
|
|
195
197
|
// ran in no sandboxed run), but the ones that would be a way out are hidden: Docker's,
|
|
196
198
|
// and your session's (its bus starts commands outside, through systemd-run; X11 and
|
|
@@ -264,12 +266,15 @@ function noteSession(path, code, resumed) {
|
|
|
264
266
|
// A folder whose last run stopped on questions, with nothing changed since
|
|
265
267
|
// that could answer them, gets no run: { blocked, questions, note }
|
|
266
268
|
// (blockedAgain). force starts one anyway (something changed elsewhere).
|
|
267
|
-
|
|
269
|
+
// A folder with nothing to work from gets none either, forced or not (nothingToDo).
|
|
270
|
+
function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
268
271
|
let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
269
272
|
if (isParked(repoPath)) return { blocked: true, parked: true, questions: 0, note: PARKED_NOTE }; // even forced: that's what parking is for
|
|
273
|
+
const lic = licenceGate(repoPath); if (lic) return lic; // Free's limits, and never Symbiot's own code (licence.mjs); even forced
|
|
270
274
|
let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
|
|
271
275
|
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
272
276
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
277
|
+
const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
|
|
273
278
|
const scoped = withScope(withConnectors(tmpl, repoPath), repoPath), box = isClaudeCmd(scoped) && trustFull() ? sandboxFor(repoPath, scoped) : null; // a repo run's sandbox, on top of the membrane
|
|
274
279
|
tmpl = withStream(withTrust(scoped, box));
|
|
275
280
|
const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
|
|
@@ -280,9 +285,10 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
280
285
|
noteUntracked(repoPath);
|
|
281
286
|
const lock = join(repoPath, ".symbiot", LOCK);
|
|
282
287
|
const own = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null, boxEnv = sandboxEnv(box);
|
|
283
|
-
const
|
|
288
|
+
const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
|
|
289
|
+
const env = own || boxEnv || owner ? { ...(own || {}), ...(boxEnv || {}), ...(owner || {}) } : null;
|
|
284
290
|
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) => {
|
|
285
|
-
if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
291
|
+
if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
286
292
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
287
293
|
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
288
294
|
setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
|
|
@@ -296,6 +302,44 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
296
302
|
noteRun(e);
|
|
297
303
|
return e;
|
|
298
304
|
}
|
|
305
|
+
// ---- nothing to do: no run ----------------------------------------------------
|
|
306
|
+
// A run in ~ started with no .symbiot/TASKS.md there, found nothing to action and
|
|
307
|
+
// only cost a session (2026-10-07). A run needs something to work from: an
|
|
308
|
+
// unticked task, or an answer that came in since the folder's last run started
|
|
309
|
+
// (an approved post or draft, a reply to its question: those carry on with every
|
|
310
|
+
// task ticked, and Marketing's folder may have no TASKS.md at all). Else the note
|
|
311
|
+
// for { blocked, idle }, even forced: Start it anyway would start the same
|
|
312
|
+
// nothing. Briefs are written before their run starts (actNow, draftReply,
|
|
313
|
+
// pushTasks), and a held one lands first (runHandoff), so they count. Only the
|
|
314
|
+
// run that restarts a lost conversation skips this: its answer is older than the
|
|
315
|
+
// failed resume's start.
|
|
316
|
+
const OPEN_TASK = /^\s*(?:[-*+]|\d+[.)])\s*\[ \]/m;
|
|
317
|
+
function nothingToDo(path) {
|
|
318
|
+
const md = readSymbiot(path, "TASKS.md"); if (OPEN_TASK.test(md)) return "";
|
|
319
|
+
let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
|
|
320
|
+
const last = [...readSymbiot(path, "agent.log").matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop();
|
|
321
|
+
if (answered && answered > (last ? Date.parse(last[1]) || 0 : 0)) return "";
|
|
322
|
+
const name = path === homedir() ? "~" : path.split(/[\\/]/).filter(Boolean).pop() || path;
|
|
323
|
+
return md.trim() ? `Not started: every task in ${name}'s .symbiot/TASKS.md is ticked and no answer has come in since its last run, so an agent there would have nothing to do. Send it a task first.`
|
|
324
|
+
: `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
|
|
325
|
+
}
|
|
326
|
+
// ---- Symbiot Free and Pro (licence.mjs) -------------------------------------
|
|
327
|
+
// A run that Free doesn't cover doesn't start; it says why, the way a parked lane does.
|
|
328
|
+
// Nothing running is touched. Free's 3 projects are the first 3 it works in (Settings
|
|
329
|
+
// can free one); the marketing lane is Pro.
|
|
330
|
+
function licenceGate(repoPath, st = licenceState()) {
|
|
331
|
+
const self = canWorkIn(repoPath, st); if (!self.ok) return { blocked: true, self: true, questions: 0, note: self.why };
|
|
332
|
+
if (isPro(st)) return null;
|
|
333
|
+
if (repoPath === MARKETING_DIR) { const m = can("marketing", {}, st); return { blocked: true, pro: true, questions: 0, note: m.why }; }
|
|
334
|
+
const running = HANDOFFS.filter((x) => x.handoff && x.status === "running" && x.path !== repoPath).length;
|
|
335
|
+
const a = can("agents", { running }, st); if (!a.ok) return { blocked: true, pro: true, questions: 0, note: a.why };
|
|
336
|
+
const cfg = loadConfig(), L = cfg.licence || {}, mine = Array.isArray(L.projects) ? L.projects : [];
|
|
337
|
+
if (!mine.includes(repoPath)) {
|
|
338
|
+
const p = can("projects", { count: mine.length }, st); if (!p.ok) return { blocked: true, pro: true, questions: 0, note: p.why + " Settings → Symbiot Pro can swap one of your 3 for this." };
|
|
339
|
+
cfg.licence = { ...L, projects: [...mine, repoPath] }; saveConfig(cfg);
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
299
343
|
// ---- a parked lane: its tasks start no runs ----------------------------------
|
|
300
344
|
// A lane blocked on something only the user can give (a token from Meta) ran 27
|
|
301
345
|
// times: every answer, handover, Go and Start it anyway started it again, to say
|
|
@@ -321,7 +365,12 @@ function parkLane(path, on) {
|
|
|
321
365
|
// test stopped halfway would be void), stops the urgent lane's own routine run (its
|
|
322
366
|
// changes stay in the folder, and the next run carries on from them) so the urgent
|
|
323
367
|
// one starts now, and when that run ends, urgentDone unparks those lanes in their
|
|
324
|
-
// old order and starts the ones with work waiting. Lanes you parked stay parked
|
|
368
|
+
// old order and starts the ones with work waiting. Lanes you parked stay parked,
|
|
369
|
+
// except the urgent lane itself: on 2026-10-09 symbiot was parked, so every urgent
|
|
370
|
+
// handover there parked marketing, started nothing, and still said "it's already on
|
|
371
|
+
// urgent work, and takes this next" while 0 agents worked. Asking for urgent work in
|
|
372
|
+
// a lane is the newest thing you've said about it, so it's unparked for it; and if
|
|
373
|
+
// its run still doesn't start, urgentUndo takes back what urgentFirst did.
|
|
325
374
|
const CAPS_OK = /^(README|TODO|FIXME|HTML|JSON|YAML|HTTP|HTTPS|OAUTH|CHANGELOG|TASKS|ANSWERS|QUESTIONS|HANDOFF|REMEMBER|AWAITING|SKIPPED|LICENSE|CLAUDE|AGENTS|NOTE|ASAP|SAST|UTC|GPU|CPU|RAM|USB|SDK|JDK|API|URL|CSS|DNS|SSH|SMS|PDF|PNG|SVG|MCP|LLM|CLI|ENV|PATH|HOME|WIP|ETA)$/;
|
|
326
375
|
function isUrgent(text) {
|
|
327
376
|
const t = String(text || "");
|
|
@@ -338,24 +387,43 @@ function stopRun(path) {
|
|
|
338
387
|
try { process.kill(-r.pid, "SIGTERM"); } catch { try { process.kill(r.pid, "SIGTERM"); } catch { return false; } } // its own process group (track: detached)
|
|
339
388
|
return true;
|
|
340
389
|
}
|
|
341
|
-
// lanes: the paths to consider ({ name: path }); returns { parked: [names],
|
|
390
|
+
// lanes: the paths to consider ({ name: path }); returns { parked: [names], parkedPaths,
|
|
391
|
+
// stopped, unparked (it was parked: now it isn't), busy: "urgent" | "routine" | "" (a run
|
|
392
|
+
// there now: one already on urgent work, which this waits for, or one it couldn't stop) }
|
|
342
393
|
function urgentFirst(path, { lanes = null, stop = stopRun, running = runningHandoff } = {}) {
|
|
343
|
-
if (!path) return { parked: [], stopped: false };
|
|
394
|
+
if (!path) return { parked: [], parkedPaths: [], stopped: false, unparked: false, busy: "" };
|
|
344
395
|
const u = urgentState() || { at: Date.now(), paths: [], parked: [], runs: [] };
|
|
345
|
-
if (!u.paths.includes(path)) u.paths.push(path);
|
|
346
|
-
if (u.parked.includes(path))
|
|
396
|
+
if (!u.paths.includes(path)) { u.paths.push(path); u.at = Date.now(); }
|
|
397
|
+
if (u.parked.includes(path)) u.parked = u.parked.filter((p) => p !== path); // urgent itself now
|
|
398
|
+
const unparked = isParked(path); if (unparked) parkLane(path, false);
|
|
347
399
|
const all = lanes || Object.fromEntries(loadRuns().map((r) => [String(r.name || r.path.split("/").pop()), r.path]));
|
|
348
|
-
const parked = [], already = parkedPaths();
|
|
400
|
+
const parked = [], mine = [], already = parkedPaths();
|
|
349
401
|
for (const [name, p] of Object.entries(all)) {
|
|
350
402
|
if (!p || u.paths.includes(p) || already.includes(p)) continue; // yours stay yours
|
|
351
403
|
if (!running(p) && !readSymbiot(p, HELD)) continue; // nothing going or waiting there
|
|
352
|
-
parkLane(p, true); u.parked.push(p); parked.push(name);
|
|
404
|
+
parkLane(p, true); u.parked.push(p); parked.push(name); mine.push(p);
|
|
353
405
|
}
|
|
354
|
-
// its own lane: a routine run stops so this one starts now; one that's already on urgent
|
|
355
|
-
|
|
406
|
+
// its own lane: a routine run stops so this one starts now; one that's already on urgent
|
|
407
|
+
// work keeps going, and counts as the urgent run, so its end resumes what was parked
|
|
408
|
+
const busy = running(path), onIt = !!busy && (u.runs.includes(busy.id) || hasOpenUrgent(readSymbiot(path, "TASKS.md")));
|
|
409
|
+
if (onIt && busy.id && !u.runs.includes(busy.id)) u.runs.push(busy.id);
|
|
356
410
|
const stopped = !!busy && !onIt && stop(path);
|
|
357
411
|
saveUrgent(u);
|
|
358
|
-
return { parked, stopped };
|
|
412
|
+
return { parked, parkedPaths: mine, stopped, unparked, busy: !busy ? "" : onIt ? "urgent" : stopped ? "" : "routine" };
|
|
413
|
+
}
|
|
414
|
+
// The urgent lane's run didn't start (no agent command, a step of yours first, its
|
|
415
|
+
// questions unanswered) and nothing runs there: nothing will end to resume the lanes
|
|
416
|
+
// urgentFirst parked, so they're unparked now, and the lane leaves the urgent record.
|
|
417
|
+
function urgentUndo(path, did, { running = runningHandoff } = {}) {
|
|
418
|
+
if (!path || !did || running(path)) return false;
|
|
419
|
+
const u = urgentState(); if (!u) return false;
|
|
420
|
+
const back = (did.parkedPaths || []).filter((p) => u.parked.includes(p));
|
|
421
|
+
for (const p of back) if (parkedPaths().includes(p)) parkLane(p, false);
|
|
422
|
+
u.parked = u.parked.filter((p) => !back.includes(p));
|
|
423
|
+
u.paths = u.paths.filter((p) => p !== path);
|
|
424
|
+
saveUrgent(u.paths.length ? u : null);
|
|
425
|
+
if (!u.paths.length && u.parked.length) resumeParked(u.parked); // parked by an earlier urgent ask that never ran either
|
|
426
|
+
return true;
|
|
359
427
|
}
|
|
360
428
|
// the urgent lane's run started: when it ends, the parked work resumes (runHandoff's exit)
|
|
361
429
|
function noteUrgentRun(path, id) { const u = urgentState(); if (u && u.paths.includes(path) && id && !u.runs.includes(id)) { u.runs.push(id); saveUrgent(u); } }
|
|
@@ -376,11 +444,13 @@ function resumeParked(paths, start = runHandoff) {
|
|
|
376
444
|
return { unparked: paths, resumed };
|
|
377
445
|
}
|
|
378
446
|
// The app restarted under an urgent run (its exit never came), or it never started:
|
|
379
|
-
// the parked lanes don't wait forever.
|
|
380
|
-
|
|
447
|
+
// the parked lanes don't wait forever. A record with no run going in any urgent lane is
|
|
448
|
+
// over once URGENT_GRACE has passed (a stopped routine run takes a moment to exit, and
|
|
449
|
+
// the urgent one starts then): before, one whose run never started held marketing for 6h.
|
|
450
|
+
const URGENT_MAX = 6 * 3600 * 1000, URGENT_GRACE = 2 * 60 * 1000;
|
|
381
451
|
function urgentSweep({ running = runningHandoff, now = Date.now(), start = runHandoff } = {}) {
|
|
382
452
|
const u = urgentState(); if (!u) return null;
|
|
383
|
-
const ended =
|
|
453
|
+
const ended = !u.paths.some((p) => running(p)) && (u.runs.length > 0 || now - (u.at || 0) >= URGENT_GRACE);
|
|
384
454
|
if (!ended && now - (u.at || 0) < URGENT_MAX) return null;
|
|
385
455
|
saveUrgent(null);
|
|
386
456
|
return resumeParked(u.parked, start);
|
|
@@ -999,7 +1069,7 @@ const HARM_RULE = /^Bash\(\s*(rm|dd|mkfs|chmod|chown|shred|truncate)\b/i;
|
|
|
999
1069
|
const wideRule = (r) => ANY_RULE.test(r) || (HARM_RULE.test(r) && /\*/.test(r));
|
|
1000
1070
|
// Whatever an agent is allowed, it never edits Symbiot's own settings: that's where
|
|
1001
1071
|
// what agents may do is kept, so an agent could widen its own permissions there.
|
|
1002
|
-
const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"); return [`Edit(${c})`, `Write(${c})`]; };
|
|
1072
|
+
const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"), s = join(homedir(), ".config", "symbiot", "secrets.json"); return [`Edit(${c})`, `Write(${c})`, `Read(${s})`, `Edit(${s})`, `Write(${s})`]; };
|
|
1003
1073
|
function installAllowlist(path) {
|
|
1004
1074
|
let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
|
|
1005
1075
|
const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
|
|
@@ -1190,4 +1260,4 @@ function agentsList() {
|
|
|
1190
1260
|
}).concat(earlierRuns());
|
|
1191
1261
|
}
|
|
1192
1262
|
|
|
1193
|
-
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, linkReach, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentDone, urgentSweep, urgentState };
|
|
1263
|
+
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, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
|
package/claudesetup.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// sign-in page in the browser and finishes by itself once they've signed in. If the
|
|
5
5
|
// page shows a code instead (the browser couldn't hand it back), setup has a box for
|
|
6
6
|
// it, which goes to the sign-in that's waiting. SYMBIOT_CLAUDE_INSTALL_CMD replaces
|
|
7
|
-
// the installer (tests).
|
|
7
|
+
// the installer (tests); SYMBIOT_NO_SIGNIN turns both off (the smoke test presses every button).
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { claudeState, claudeSpawn } from "./ai.mjs";
|
|
@@ -19,6 +19,7 @@ function installCmd(platform = process.platform) {
|
|
|
19
19
|
}
|
|
20
20
|
function installClaude() {
|
|
21
21
|
if (S.installing) return claudeSetup();
|
|
22
|
+
if (process.env.SYMBIOT_NO_SIGNIN && !process.env.SYMBIOT_CLAUDE_INSTALL_CMD) { S.installError = "Installing is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
|
|
22
23
|
S.installing = true; S.installError = ""; S.log = "";
|
|
23
24
|
const c = installCmd();
|
|
24
25
|
let ch; try { ch = spawn(c.cmd, c.args, { cwd: homedir(), shell: c.shell, windowsHide: true, env: process.env }); } catch (e) { S.installing = false; S.installError = String((e && e.message) || e); return claudeSetup(); }
|
|
@@ -33,6 +34,8 @@ function installClaude() {
|
|
|
33
34
|
}
|
|
34
35
|
function signInClaude() {
|
|
35
36
|
if (S.signingIn) return claudeSetup();
|
|
37
|
+
// tests press every button: a real sign-in would open the browser on someone's real account
|
|
38
|
+
if (process.env.SYMBIOT_NO_SIGNIN) { S.signinError = "Signing in is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
|
|
36
39
|
S.signingIn = true; S.signinError = "";
|
|
37
40
|
let ch; try { ch = claudeSpawn(["auth", "login", "--claudeai"], { cwd: homedir(), stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { S.signingIn = false; S.signinError = String((e && e.message) || e); return claudeSetup(); }
|
|
38
41
|
S.child = ch;
|
package/core.mjs
CHANGED
|
@@ -21,17 +21,43 @@ async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"
|
|
|
21
21
|
// ---- config ---------------------------------------------------------------
|
|
22
22
|
const CONFIG_DIR = join(homedir(), ".config", "symbiot");
|
|
23
23
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
24
|
+
// Symbiot's own keys (your AI's API key, the app's token) live apart from its
|
|
25
|
+
// settings, in secrets.json: a run outside the sandbox could `cat` config.json, and
|
|
26
|
+
// with the token call Symbiot's own API and change the agent command, which the
|
|
27
|
+
// membrane never sees. The membrane (guard.mjs) and a run's sandbox (agents.mjs
|
|
28
|
+
// SANDBOX_SECRETS) refuse secrets.json. loadConfig joins the two and saveConfig
|
|
29
|
+
// splits them, so nothing else changes; a config.json from before still reads, and
|
|
30
|
+
// its keys move out on the next save (the app saves once as it starts: moveSecrets).
|
|
31
|
+
const SECRETS_PATH = join(CONFIG_DIR, "secrets.json"), SECRET_KEYS = ["apiKey", "appToken"];
|
|
32
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
33
|
+
function splitSecrets(cfg) {
|
|
34
|
+
const pub = { ...cfg }, sec = {};
|
|
35
|
+
for (const k of SECRET_KEYS) if (k in pub) { sec[k] = pub[k]; delete pub[k]; }
|
|
36
|
+
for (const [k, v] of Object.entries(pub)) if (isObj(v) && "apiKey" in v) { sec[k] = { apiKey: v.apiKey }; pub[k] = { ...v }; delete pub[k].apiKey; } // a provider's: { anthropic: { apiKey, model } }
|
|
37
|
+
return { pub, sec };
|
|
38
|
+
}
|
|
39
|
+
const readJson = (f) => { try { const o = JSON.parse(readFileSync(f, "utf8")); return isObj(o) ? o : {}; } catch { return {}; } };
|
|
24
40
|
function loadConfig() {
|
|
25
|
-
|
|
41
|
+
const cfg = readJson(CONFIG_PATH), sec = readJson(SECRETS_PATH);
|
|
42
|
+
for (const [k, v] of Object.entries(sec)) {
|
|
43
|
+
if (SECRET_KEYS.includes(k)) { if (!(k in cfg)) cfg[k] = v; } // one still in config.json is newer (an older Symbiot wrote it)
|
|
44
|
+
else if (isObj(v) && "apiKey" in v && !(isObj(cfg[k]) && "apiKey" in cfg[k])) cfg[k] = { ...(isObj(cfg[k]) ? cfg[k] : {}), apiKey: v.apiKey };
|
|
45
|
+
}
|
|
46
|
+
return cfg;
|
|
26
47
|
}
|
|
27
48
|
function saveConfig(cfg) {
|
|
28
49
|
try {
|
|
29
50
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
-
|
|
51
|
+
const { pub, sec } = splitSecrets(cfg || {});
|
|
52
|
+
writeFileSync(SECRETS_PATH, JSON.stringify(sec, null, 2) + "\n", { mode: 0o600 }); // first: a key is never only in neither
|
|
53
|
+
try { chmodSync(SECRETS_PATH, 0o600); } catch {}
|
|
54
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(pub, null, 2) + "\n");
|
|
31
55
|
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
32
56
|
return true;
|
|
33
57
|
} catch { return false; }
|
|
34
58
|
}
|
|
59
|
+
// Keys still in config.json (from before secrets.json): moved out. True if any were.
|
|
60
|
+
function moveSecrets() { const raw = readJson(CONFIG_PATH); return Object.keys(splitSecrets(raw).sec).length ? saveConfig(loadConfig()) : false; }
|
|
35
61
|
// ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
|
|
36
62
|
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
37
63
|
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
@@ -180,4 +206,4 @@ function repoState(repoPath) {
|
|
|
180
206
|
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
181
207
|
}
|
|
182
208
|
|
|
183
|
-
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
|
209
|
+
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, SECRETS_PATH, loadConfig, saveConfig, moveSecrets, splitSecrets, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
package/guard.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// - push to main/master, or force-push; publish a package
|
|
8
8
|
// - delete outside the agent's own folder, or wipe a disk
|
|
9
9
|
// - sudo, or pipe something from the internet into a shell
|
|
10
|
-
// - read SSH keys or cloud credentials
|
|
10
|
+
// - read SSH keys or cloud credentials, or Symbiot's own keys (secrets.json: your AI's
|
|
11
|
+
// key and the app's token); change Symbiot's own settings
|
|
11
12
|
// - open the user's running Symbiot in a browser (a check uses a sandbox copy)
|
|
12
13
|
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
13
14
|
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
@@ -18,13 +19,18 @@ import { execFileSync } from "node:child_process";
|
|
|
18
19
|
import { readFileSync } from "node:fs";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
|
|
21
|
-
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
|
|
22
|
+
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/secrets.json"];
|
|
22
23
|
const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
|
|
23
24
|
const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
|
|
24
25
|
const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
|
|
25
26
|
const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
|
|
26
27
|
function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
|
|
27
|
-
function symbiotConfig(p, home) {
|
|
28
|
+
function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); return p === join(d, "config.json") || p === join(d, "licence-revoked.json"); }
|
|
29
|
+
// Symbiot's own installed files (where this guard runs from): never edited by an agent, so
|
|
30
|
+
// it can't be asked to take out its own limits or rules. Ghost AI's owner key lets its own
|
|
31
|
+
// runs through (SYMBIOT_OWNER, set by Symbiot, not by the agent).
|
|
32
|
+
const SELF = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
33
|
+
const selfFile = (p) => !process.env.SYMBIOT_OWNER && under(p, SELF);
|
|
28
34
|
|
|
29
35
|
// the branch a bare `git push` pushes: the one checked out in cwd
|
|
30
36
|
function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
|
|
@@ -36,6 +42,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
36
42
|
const s = part.trim(); if (!s) continue;
|
|
37
43
|
const w = s.split(/\s+/);
|
|
38
44
|
if (/^(sudo|su|doas)$/.test(w[0])) return { why: "sudo: only you run things as root" };
|
|
45
|
+
if (!process.env.SYMBIOT_OWNER && (s.includes(SELF + "/") || s.includes(SELF + " ") || s.endsWith(SELF))) return { why: "Symbiot doesn't edit its own code" };
|
|
39
46
|
if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
|
|
40
47
|
if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(w[0]) || (/^dd$/.test(w[0]) && /\bof=\/dev\//.test(s))) return { why: "that writes a disk directly" };
|
|
41
48
|
if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
|
|
@@ -65,7 +72,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
65
72
|
const own = w[0] === "scp" ? SCP_VALUE : null;
|
|
66
73
|
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
|
|
67
74
|
}
|
|
68
|
-
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/config\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
75
|
+
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/(config|secrets)\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
69
76
|
}
|
|
70
77
|
if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c)) return { why: "running a script straight from the internet" };
|
|
71
78
|
// a browser on the user's running Symbiot: each page it opened counted as a new window and
|
|
@@ -82,6 +89,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
|
|
|
82
89
|
const abs = expand(p, home, cwd);
|
|
83
90
|
if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
|
|
84
91
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
|
|
92
|
+
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && selfFile(abs)) return { why: "Symbiot doesn't edit its own code" };
|
|
85
93
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
|
|
86
94
|
return null;
|
|
87
95
|
}
|