@elu-labs/elu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
package/SECURITY.md ADDED
@@ -0,0 +1,333 @@
1
+ # Security
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Report it privately. Open the repository's **Security** tab and choose **Report a
6
+ vulnerability**, or go straight to
7
+ <https://github.com/ELU-Labs/elu/security/advisories/new>. Please do not open a
8
+ public issue or post reproduction details before a fix is out.
9
+
10
+ Say what you ran: the output of `elu --version`, your operating system,
11
+ the vendor command line and its release, the session's permission mode, and the steps.
12
+ Fixes are made on `main`; there are no older versions that get fixes.
13
+
14
+ ## What is trusted
15
+
16
+ ELU Code launches installed vendor CLIs. The executables and your local installation are
17
+ trusted. Repository content, check scripts, prompts, and model responses are untrusted.
18
+ Only the owner can approve a push.
19
+
20
+ Prompts explain the task but are not security controls. This tool cannot prove a change is
21
+ correct, and model review is not a substitute for a person reviewing a pull request.
22
+
23
+ ## What an agent may do
24
+
25
+ It depends on the session's permission mode ([docs/permissions.md](docs/permissions.md)).
26
+
27
+ - **Read only** is the profile of every reviewer, every teammate question and every task
28
+ saved without a mode. It gives no shell tools unless the Claude role declares explicit
29
+ command prefixes. ELU Code adds none by itself: the test and build scripts it finds in a
30
+ project run only as checks, inside the check sandbox, and a prefix reaches the model only
31
+ from the project's settings file (`roles.lead.commands`). Ordinary questions in Read only
32
+ have only read-only file tools, with no shell, workspace creation, or check execution.
33
+ - **Ask for approval** and **Approve for me** give the session's model, and a bot that
34
+ works in the session, a shell inside the vendor's own sandbox, with no network. In Ask
35
+ for approval an action outside it asks you where the vendor can ask during a turn
36
+ (Claude Code, and Antigravity and Grok Build once their one-time check has proven it);
37
+ on Codex, which cannot, it is refused and reported. In Approve for me a reviewer model
38
+ decides instead of you. The sandbox differs by vendor: Antigravity in Ask for approval
39
+ has none, so every command needs your approval and one you approve runs outside any
40
+ sandbox, and Grok Build's sandbox blocks the network on Linux only. The vendor pages in
41
+ [docs/vendors](docs/vendors) have the details. In both modes your session's answer
42
+ turns, and a working bot's, also read your home folder without asking, except the
43
+ secret folders ([docs/permissions.md](docs/permissions.md)).
44
+ - **Full access** gives an unsandboxed shell. A Full access shell can read anything your
45
+ user can, except where the vendor's deny rules cover a file tool.
46
+
47
+ The helpers a bot hands work to run under the same mode and the same denied commands as
48
+ the bot.
49
+
50
+ A teammate's question starts only after you accept it, unless you set that teammate to be
51
+ accepted without asking. Its answer then goes back to them without waiting for you to read
52
+ it, unless you chose to read that teammate's replies first. A reply that looks like it
53
+ holds a key or a token is always held for you; that check looks for the shapes of secrets
54
+ and is not a guarantee. Only a project's own folder can be shared, never your home folder,
55
+ and a shared project answers only the teams it was shared with.
56
+
57
+ A picture goes to a teammate only with a message you typed yourself that names it. A path
58
+ in text you pasted into the message counts the same as one you typed, and the line that
59
+ says how many pictures went (`with 2 pictures`) comes after the message is sent. A model's
60
+ draft never attaches a file, and a picture in a folder that holds secrets is refused; a
61
+ hard link in another folder to a file in such a folder is not recognized, and is attached.
62
+ "A picture" means a file whose first bytes say PNG, JPEG, GIF or WebP: the rest of the file
63
+ is not checked, and it is sent as it is, with what it records about the place, the device
64
+ and the time. A picture a teammate sends you is kept in ELU Code's private folder under a
65
+ name made here, readable only by you, and is never run. When you accept their question, its
66
+ run can read the shared project and a copy of those pictures, and nothing else. The copy
67
+ sits in the temporary directory, closed to other users, until the run ends; another session
68
+ of yours that can read the temporary directory could read it in that time.
69
+
70
+ Only Full access uses a vendor permission bypass, and only for the model of a session you
71
+ gave it and the bots that work in that session; every other launcher asks or denies.
72
+ Unsupported policies stop before a role launches. In Read only, Ask for approval and
73
+ Approve for me, task metadata and source checks stay outside agent write access: the file
74
+ tools and the sandboxed shell may not change the repository's Git folder or the protected
75
+ project files, and may not read ELU Code's records in that folder; a command that leaves
76
+ the sandbox asks first. Full access can change them, as it can change anything your user
77
+ can.
78
+
79
+ A sandboxed command reads the rest of the Git folder, so `git status`, `git diff` and
80
+ `git log` work without an approval. Through git it can read the repository's whole
81
+ history: every committed version of every file, including a file committed once and later
82
+ removed, and committed files the sandbox hides by name in the worktree (`.env*`, `*.pem`,
83
+ `.npmrc`). It can read the Git settings file, with its remote URLs and any token written
84
+ into one, and the stash and the reflog. The sandbox has no network, so a sandboxed
85
+ command cannot send what it reads anywhere; what it prints goes to the model, as all
86
+ output does, and into the session's record. `git add`, `git commit` and anything else
87
+ that writes in the folder stay refused. A teammate's question is given tracked files
88
+ only: its commands read none of the folder.
89
+
90
+ Vendor tool permissions restrict direct file tools. Claude Code's own sandbox fences only
91
+ shell commands and is never required: where it cannot start, commands run outside it under
92
+ the same rules (Ask asks you for each, a reviewer model decides in Approve for me, a Read
93
+ only role keeps its command prefixes).
94
+
95
+ Claude shell roles are labelled "temp folder writable": shared temp directories are denied,
96
+ but the vendor can retain its own writable temporary folder outside the worktree. This
97
+ exception is visible in `elu policy show`. In Read only, Ask for approval and Approve for
98
+ me ELU Code names that folder itself where its path is short enough for Claude Code to
99
+ use: a private one under your cache folder, or in your runtime folder where that path is
100
+ too long, made for the run and removed when it ends (`docs/vendors/claude.md`).
101
+
102
+ ## What teammates are told
103
+
104
+ A project is shared with `elu share` or `/share`, and also when a teammate is added:
105
+ `elu invite` and `elu join` share the folder they run in when it is an ELU Code project
106
+ (`--project PATH` names another), and `/invite` and `/join` share the open project. A share has status
107
+ on unless you made it with `elu share --no-status`; `/share`, and `elu share` without that
108
+ flag, turn status on again. While status is on, and the project has an `origin` remote, the
109
+ teammates on a team it is shared with are told two things about it, and neither is checked
110
+ for secrets:
111
+
112
+ - **Status rows**, one for each live editing task (one being prepared, running, ready or
113
+ waiting for you; a plain conversation has none): the first line of the request it works
114
+ on, up to 80 characters; its state and what is at work on it (`lead`, `checks`, a shared
115
+ bot's `@name`, or `bot` for another bot); and the paths, relative to the repository, of up
116
+ to 200 files it has changed or added. They are used to warn about overlapping work. The
117
+ peer daemon reads them every 10 seconds and sends them when they change, and to each
118
+ teammate when they connect.
119
+ - **Tasks**: for each session, the approved Task its work is on and any finished in the
120
+ last 24 hours, with the title, description, goal, objectives, priority, severity, state,
121
+ subtasks, which objectives are met, who works on it, and its times. They are sent within
122
+ 5 seconds of a change, and to each teammate when they connect.
123
+
124
+ Every teammate on any of your teams is also told, whatever the status of your shares, this
125
+ machine's name (the peer setting `box`, by default the first part of its host name), the
126
+ bots you share and what each is for, and the names of the projects shared with their team.
127
+ Nothing else about your work is told: not a Task you have not approved or a rejected one,
128
+ the rest of your request, the chat, file contents or absolute paths. A teammate's question
129
+ and its answer are another matter, covered above.
130
+
131
+ What a teammate tells is untrusted text: each field is checked and cut to its limit before
132
+ it is kept, and it counts only in a project you share with a team that teammate is on. Your
133
+ own model and bots in that project are given the titles and goals as data, marked as a
134
+ teammate's text and never as a request; a teammate's question running on your machine is
135
+ given none of it.
136
+
137
+ ## The never list
138
+
139
+ The never list (push, tag, merge, deploy, publish) is enforced only where the means are
140
+ missing: Read only and Ask for approval, with the one exception below. In Approve for me
141
+ and Full access it is prompt text plus the vendor's deny rules, which match command text
142
+ and can be sidestepped by an obscured command. ELU Code's own publish step still needs
143
+ your confirmation and still refuses a default branch in every mode.
144
+
145
+ One entry can open. A bot that is allowed to merge pull requests (`@elu` comes allowed; a
146
+ bot you make is not) may run `gh pr merge` in its own working turn on Claude Code: its deny
147
+ rules leave that command out, and its `gh` stand-in lets it through. In Ask for approval
148
+ you are asked before each merge. In Approve for me the reviewer model decides and you are
149
+ not asked, and Full access does not ask. A turn its schedule started runs with Approve for
150
+ me at most, so in a session set to Approve for me or Full access the hourly `@elu` can
151
+ merge with nobody watching. Push, tag, `git merge`, deploy and publish stay blocked for
152
+ that bot as for every other.
153
+
154
+ A rule on command text does not see what a script runs, and Codex has no deny rules at
155
+ all. So every vendor's session, in every mode but Read only, also gets stand-in commands:
156
+ a private folder (`~/.local/share/elu-code/never-list`, mode 0700) goes first on the
157
+ session's PATH with one script per command word, and each refuses the never-list call by
158
+ name whatever runs it, a script or `sh -c` included. Claude Code builds PATH again for
159
+ each command, so ELU Code names a one-line file in `CLAUDE_ENV_FILE` that puts the folder
160
+ back in front. This was seen with the real Codex and the real Claude Code in Ask for
161
+ approval and Approve for me, not in Full access; with Antigravity and Grok Build only in
162
+ tests against stand-in tools. They are a tripwire as well, not a sandbox. These get
163
+ past them: a full path to the real command (`/usr/bin/git push`), a changed PATH, a Git
164
+ alias (`git -c alias.p=push p`) and another program that does the same work (`gh api`,
165
+ `git send-pack`). Two things pass on purpose, because they are not publishing: a
166
+ `git push` to a folder on this machine (a path or a `file://` URL, as git itself resolves
167
+ the remote), and a `git merge` or `git tag` in a scratch repository that lies, links
168
+ resolved, inside the system temporary directory. A push to another machine, or one git
169
+ cannot place, is refused before anything is sent. A project kept under the temporary
170
+ directory is treated as scratch. A session in Full access can also rewrite the folder; it is written
171
+ again, and cleared of anything else, when the next session starts. In Ask for approval
172
+ and Approve for me the vendor's sandbox, where it runs one, still stands behind them; in
173
+ Full access nothing does.
174
+
175
+ ## Approvals
176
+
177
+ Approvals have limits. They are a convenience, not a boundary against an agent that is
178
+ already outside the sandbox: a process running as your user can forge an answer file. Ask
179
+ for approval keeps the agent inside the sandbox until you say yes; Approve for me trusts
180
+ the vendor's reviewer model; Full access trusts your choice. Request text is model output:
181
+ control characters are stripped, otherwise the command, path or URL is shown exactly.
182
+
183
+ ## Configuration, MCP servers and hooks
184
+
185
+ A project's own settings and instructions (`.elu-code/config.yaml`, `instructions.md` and
186
+ `context.md`) come with its folder, so they count as yours only once you have approved
187
+ their exact content on this machine. ELU Code shows a file that is new or changed and asks
188
+ once whether to use it. Until you approve a first version, sessions run on the default
189
+ settings and without those instructions. After that, the version you approved last stays in
190
+ use until you approve a change: a file that changed, that you turned down or that was
191
+ removed does not put the defaults back. A file over 100 KB is never used. Scripted runs
192
+ are refused while the settings file in the folder is not one you approved. Your answers are
193
+ kept by content hash, with the text you approved last, in ELU Code's own state folder,
194
+ never in the project. Like approvals, this is not a boundary against a process that already
195
+ runs as your user: a Full access session can write that folder, and so can a command that
196
+ was approved to leave the sandbox in Ask for approval or in Approve for me, where the
197
+ approver is the vendor's reviewer model. With a Codex model, the repository's own
198
+ `AGENTS.md` reaches the model as Codex itself reads it, and this step does not cover it.
199
+
200
+ ELU Code starts one MCP server of its own, the approval relay, and only for a session with
201
+ permissions: a dependency-free process that turns Claude Code's permission prompts into
202
+ requests you answer in the terminal. Its Claude launcher otherwise uses strict MCP
203
+ configuration, holding only the servers ELU Code passes, and loads no settings files
204
+ (`--setting-sources ''`). The Codex reviewer ignores user configuration and refuses
205
+ project or managed configuration that has not passed isolation tests.
206
+
207
+ Your own Claude Code MCP servers (`claude mcp list`: the ones in `~/.claude.json`, for all
208
+ folders and for the project folder) are given to the model of your sessions on Claude Code
209
+ and to each bot that works there, unless you turn one off for the project with
210
+ `/mcp off NAME`. A bot also gets the servers its page names, even one turned off for the
211
+ project. Bots that only review or answer, a read-only turn and a teammate's question get
212
+ none. Their tools run without asking in every mode: in Read only (a task saved without a
213
+ mode), Ask for approval and Approve for me they are allowed by name, and Full access
214
+ allows everything. They run as their own processes, so neither the shell sandbox nor the
215
+ never list covers them: a server that can push, merge, post or send mail can do so
216
+ without asking you. Their settings, which can hold tokens, are copied for each run into a
217
+ private file in ELU Code's state folder and removed when the run ends.
218
+
219
+ On Claude Code, the model of your sessions and each bot that works there also get your
220
+ Claude Code instructions and memory for the project folder, as Claude Code itself would
221
+ load them: `~/.claude/CLAUDE.md`, the folder's `CLAUDE.md`, and the notes in
222
+ `~/.claude/projects/<folder>/memory`. The two instruction files and the memory index go
223
+ into the prompt of a session's first turn; the notes are copied for each run into a
224
+ private folder in the system temporary directory, which the run may read, and removed when
225
+ it ends. A teammate's question gets none of them. The folder's `CLAUDE.md` comes with the
226
+ folder, and the approval step above does not cover it.
227
+
228
+ Hooks are inspected before editing. Configured hook folders in the task worktree are
229
+ protected, and a changed hook stops checks and publication. Publishing uses an absolute
230
+ private copy of the reviewed hooks. It runs the existing pre-push guards, including the
231
+ guard in Git's common directory; commit hooks are not run. Git preparation and staging do
232
+ not execute checkout or index hooks. Push guards run with a read-only worktree, private
233
+ temporary space, and network access denied. A hook that invokes edited source inherits
234
+ these same limits. The original hook files and Git configuration are unchanged. Git
235
+ attributes and submodules that could execute repository code are refused.
236
+
237
+ Terminal requests pin a hashed policy in private task state; scripted runs pin
238
+ configuration and commands to a base commit. A session's mode, place and folders live in
239
+ that private state, never in repository files, and changing the mode between turns changes
240
+ no policy.
241
+
242
+ ## Credentials
243
+
244
+ Agents do not receive cloud or GitHub credentials from ELU Code's inherited environment.
245
+ Vendor authentication remains the vendor CLI's responsibility. ELU Code never signs you in.
246
+ It never reads Claude Code's sign-in, in the macOS Keychain or in its credentials file, and
247
+ it sends no request of its own with any vendor sign-in.
248
+
249
+ The Usage tab shows plan usage. Claude usage comes from the rate limit lines Claude Code
250
+ writes to its own output during a session that ELU Code runs. No request is made for it,
251
+ and until such a session has run, the tab says so.
252
+
253
+ Codex usage comes from Codex's own `app-server` command: ELU Code starts it and asks it for
254
+ your plan's rate limits, so the request with your Codex sign-in is made by Codex. It starts
255
+ in an empty private folder with MCP servers, hooks, apps, plugins and analytics turned off,
256
+ and no thread or turn is ever started. ELU Code asks for this check whenever `codex` is on
257
+ your PATH, including when you have it only for the check sandbox:
258
+
259
+ - when you open a project that is set up, unless another ELU Code window already has it
260
+ open, and when you finish setting one up;
261
+ - when you start a session or send a message;
262
+ - every minute while a session, or the new session form, is on screen;
263
+ - when the Usage tab opens and its last check is more than two minutes old, and when you
264
+ press `r` there.
265
+
266
+ Apart from `r`, a check asked for less than a minute after the last one is skipped.
267
+
268
+ What is saved, in `~/.config/elu-code`, is usage reduced to numbers and times
269
+ (`usage.json`), and the email and plan name of each account seen, with its last usage
270
+ (`accounts.json`). The Usage tab takes the Claude email, plan name and rate limit tier from
271
+ `~/.claude.json`. It decodes the Codex email and plan on your machine from the id token in
272
+ `auth.json` in the Codex home. No token from that file is sent, saved or shown.
273
+
274
+ ## Checks and evidence
275
+
276
+ Check scripts and pre-push guards run inside Codex's OS sandbox (`codex sandbox`, or the
277
+ copy `ELU_CODE_SANDBOX_HELPER` names), with an empty Codex home of their own, so Codex must
278
+ be installed whatever vendor your sessions use, but need not be signed in. A check that
279
+ cannot start in the sandbox fails; it never retries without isolation. Runtime directories
280
+ needed by installed check programs are readable, not writable.
281
+
282
+ Checks have a fresh private temporary directory inside the task worktree. Shared system
283
+ temporary directories are denied. Ignored output is included in the evidence digest and
284
+ blocks completion because it would be absent from the commit. Build and test scripts must
285
+ not depend on unpublished generated files.
286
+
287
+ Missing project checks leave code unverified and cannot authorize publication. Source
288
+ edits after a check or review invalidate that evidence. Changing an allowed test to assert
289
+ less is still possible and must be caught in review.
290
+
291
+ ## Output and secrets
292
+
293
+ Raw output can contain secrets. It remains in `.git/elu-code/` (or personal conversation
294
+ state before a folder has Git history), never in the PR evidence block. Review responses
295
+ get conservative pattern-based redaction, which cannot recognize all personal or business
296
+ information. Inspect the exact body before approving publication.
297
+
298
+ ## What the tests prove
299
+
300
+ Compiler and fake-CLI tests establish ELU Code's behavior. Sandbox probes establish the
301
+ installed helper's boundaries. Actual agent enforcement requires live tests on the exact
302
+ vendor release, including commands launched by a test script, outside writes, synthetic
303
+ secret reads, network requests, protected edits, configured MCP servers, and cancellation.
304
+ A model choosing not to attempt an action is not proof that the vendor refused it.
305
+
306
+ Use a tested vendor release for sensitive repositories. Operating system updates, managed
307
+ settings, and vendor updates can change enforcement.
308
+
309
+ ## Linux
310
+
311
+ A write outside the mounted files may land in a private, temporary filesystem without an
312
+ error. Sandbox probes and tests verify that the real files remain unchanged. Keep
313
+ repositories with push hooks outside shared `/tmp` and `/var/tmp` folders: the tested
314
+ helper cannot mount read-only hook inputs there.
315
+
316
+ ## Antigravity and Grok Build
317
+
318
+ Antigravity CLI runs under a private HOME holding only ELU Code's settings and hook; its
319
+ rules and that hook were designed from Google's documentation, and the steps of its
320
+ one-time check, which ELU Code runs before your first message to it, are the only proof on
321
+ a given machine. Google's FAQ says that using third-party software to access Antigravity
322
+ violates its terms and can get the account suspended or terminated, and that wrapping
323
+ `agy` in other applications is not supported on consumer plans. ELU Code wraps it: using
324
+ it is the owner's decision, and the owner's account carries the risk. Antigravity's deny
325
+ rules and hook match command text: in Approve for me and Full access the never list is a
326
+ tripwire, not a boundary.
327
+
328
+ Grok Build runs with a private HOME and every documented Claude/Cursor compatibility,
329
+ telemetry and upload switch off; the `isolation` step of its one-time check is the only
330
+ proof on a given machine, and the vendor's own issue
331
+ [xai-org/grok-build-plugin-cc#34](https://github.com/xai-org/grok-build-plugin-cc/issues/34)
332
+ says some switches are ignored. Grok's deny rules match command text: in Approve for me
333
+ and Full access the never list is a tripwire, not a boundary.
@@ -0,0 +1,62 @@
1
+ # Third-party notices
2
+
3
+ Two of the bots ELU Code comes with are adapted from skills published by others. Their
4
+ instructions are in `lib/default-bots.mjs`. Some test files are examples copied from a
5
+ published specification; they are in the repository only, not in the npm package.
6
+
7
+ ## @reviewer
8
+
9
+ Adapted from the `code-review` skill of the Engineering plugin in
10
+ [anthropics/knowledge-work-plugins](https://github.com/anthropics/knowledge-work-plugins)
11
+ (`engineering/skills/code-review/SKILL.md`, version 1.2.0), by Anthropic, licensed under the
12
+ Apache License, Version 2.0. A copy of that license is in
13
+ [LICENSES/Apache-2.0.txt](LICENSES/Apache-2.0.txt).
14
+
15
+ Changes: the review dimensions (security, performance, correctness, maintainability) and the
16
+ shape of the answer (summary, critical issues, suggestions, what looks good, verdict) are
17
+ kept. The text is rewritten for a bot that reviews the change of a session and reports its
18
+ findings as blocking or optional comments. The slash command, the connector notes and the
19
+ usage tips are left out.
20
+
21
+ ## @qa
22
+
23
+ Adapted from the `qa` skill of [garrytan/gstack](https://github.com/garrytan/gstack)
24
+ (`qa/SKILL.md`), licensed under the MIT License. The method is kept: test, report a
25
+ baseline by severity, then for each finding reproduce it, write the regression test first,
26
+ make the smallest fix, test again and classify the result, with rules for when to stop. The
27
+ text is rewritten for a bot in a session; the browser tooling, the setup steps, the report
28
+ files and the telemetry of the original are left out.
29
+
30
+ ```text
31
+ MIT License
32
+
33
+ Copyright (c) 2026 Garry Tan
34
+
35
+ Permission is hereby granted, free of charge, to any person obtaining a copy
36
+ of this software and associated documentation files (the "Software"), to deal
37
+ in the Software without restriction, including without limitation the rights
38
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
39
+ copies of the Software, and to permit persons to whom the Software is
40
+ furnished to do so, subject to the following conditions:
41
+
42
+ The above copyright notice and this permission notice shall be included in all
43
+ copies or substantial portions of the Software.
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
48
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
49
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
50
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
51
+ SOFTWARE.
52
+ ```
53
+
54
+ ## A2A specification examples
55
+
56
+ The six files in `test/fixtures/a2a/` are examples from the Agent2Agent (A2A) Protocol
57
+ specification in [a2aproject/A2A](https://github.com/a2aproject/A2A)
58
+ (`docs/specification.md`, tag `v1.0.1`), licensed under the Apache License, Version 2.0. A
59
+ copy of that license is in [LICENSES/Apache-2.0.txt](LICENSES/Apache-2.0.txt).
60
+
61
+ Changes: each file holds one example, as JSON, with a `_source` field added that names the
62
+ section of the specification and the lines it comes from.
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { repository, findRepository } from '../lib/git.mjs';
7
+ import { initPlan, applyInit, uninstallPlan, applyUninstall } from '../lib/init.mjs';
8
+ import { runPlan, runTask } from '../lib/runner.mjs';
9
+ import { readTask, saveTask, taskDir, tasks, lock } from '../lib/state.mjs';
10
+ import { runChecks, pinnedConfig } from '../lib/checks.mjs';
11
+ import { policyReport, assertPolicy } from '../lib/policy.mjs';
12
+ import { unsupportedVendors } from '../lib/detect.mjs';
13
+ import { ADAPTERS } from '../lib/adapters/index.mjs';
14
+ import { preparePublish, publish, prBody } from '../lib/publish.mjs';
15
+ import { refuse, quote, readJson } from '../lib/util.mjs';
16
+ import { recoverProcess, addUserBin } from '../lib/process.mjs';
17
+ import {formatResult}from'../lib/format.mjs';
18
+ import {botKey}from'../lib/bots.mjs';
19
+ import { sweepServers } from '../lib/mcp.mjs';
20
+
21
+ addUserBin();
22
+ // MCP settings a run could not remove because its process died (lib/mcp.mjs).
23
+ sweepServers();
24
+ // Peer commands load lib/peer/ only when used; it never imports the optional packages at load time.
25
+ const PEER_COMMANDS = new Set(['id', 'team', 'invite', 'join', 'share', 'unshare', 'peers', 'peer', 'ask', 'relay']);
26
+ const HELP = `ELU Code — terminal coding workspace\n\nRun elu anywhere to open the terminal app.\nThe first start asks for a project name; later starts open your last project.\nSessions lists every session; type a message to start one.\nKanban shows the open project's tasks. Chats holds your channels and direct messages. Type / for commands and help.\nEditing workspaces are prepared automatically. Pushes require separate approval.\n\nCommands:\n conductor [stop]\n schedule [list]\n dashboard\n init [--yes] [--dry-run]\n run <goal> [--yes] [--dry-run] [--permissions auto|full] [--place here|branch:NAME] [--folder PATH]...\n run <id> --publish [--approve-push <confirmationId>]\n status [id] [--recover]\n open <id> [--bot NAME] [--exec]\n check <id>\n policy show\n uninstall [--yes] [--dry-run]\n\nPeer network:\n id [--name N] [--export] [--import CODE]\n team create NAME | team\n invite [--team ID] [--hours N] [--project PATH]\n join CODE [--project PATH]\n share [PATH] [--as HANDLE] [--no-status] | unshare HANDLE\n peers\n peer on|off|status|install|uninstall\n ask [@NAME] [--project H] TEXT\n relay [--team ID]\n\nEvery command accepts --json. --dry-run prints the proposed task setup;\nit does not execute the task or verify the resulting implementation.\n`;
27
+ function argumentsOf(argv) {
28
+ const flags = {}; const words = [];
29
+ const boolean = new Set(['json', 'yes', 'dry-run', 'publish', 'recover', 'exec', 'help', 'version', 'daemon', 'export', 'no-status']);
30
+ const values = new Set(['role', 'bot', 'approve-push', 'name', 'import', 'team', 'hours', 'as', 'project', 'permissions', 'place']);
31
+ const repeatable = new Set(['folder']);
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const arg = argv[i];
34
+ // -h alone or straight after a command; inside a goal or a question it is a word of the text.
35
+ if (arg === '-h' && (i === 0 || (i === 1 && argv.length === 2))) { flags.help = true; continue; }
36
+ if (!arg.startsWith('--')) { words.push(arg); continue; }
37
+ const [name, inline] = arg.slice(2).split('=', 2);
38
+ if (Object.hasOwn(flags, name) && !repeatable.has(name)) refuse(`Duplicate option --${name}.`);
39
+ if (boolean.has(name) && inline === undefined) flags[name] = true;
40
+ else if (values.has(name) || repeatable.has(name)) {
41
+ const value = inline ?? argv[++i];
42
+ if (!value || value.startsWith('--')) refuse(`--${name} needs a value.`);
43
+ if (repeatable.has(name)) (flags[name] ??= []).push(value); else flags[name] = value;
44
+ } else refuse(`Unknown option ${arg}.`);
45
+ }
46
+ return { flags, words };
47
+ }
48
+ async function confirm(plan, flags) {
49
+ if (flags['dry-run']) return false;
50
+ if (flags.yes) return true;
51
+ if (flags.json || !process.stdin.isTTY) return false;
52
+ process.stdout.write(formatResult(plan));
53
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
54
+ const closed=new Promise(resolve=>prompt.once('close',()=>resolve('')));
55
+ try { const answer=await Promise.race([prompt.question('Make these writes? [y/N] ').catch(error=>{if(error.code==='ERR_USE_AFTER_CLOSE'||error.code==='ABORT_ERR')return'';throw error;}),closed]);return /^y(?:es)?$/i.test(answer.trim()); }
56
+ finally { prompt.close(); }
57
+ }
58
+ export async function main(argv = process.argv.slice(2)) {
59
+ const { flags, words } = argumentsOf(argv);
60
+ if (flags.help || words[0] === 'help') return { help: `${HELP}\nOptions:\n --help, -h\n --version\n` };
61
+ if (flags.version) return { version: readJson(fileURLToPath(new URL('../package.json', import.meta.url))).version };
62
+ if (words[0] === 'schedule') {
63
+ const schedules = await import('../lib/schedules.mjs');
64
+ // The login item runs this every minute. It prints only when something happens.
65
+ // Bots that have a schedule are counted with the schedules made with /schedule.
66
+ if (words[1] === 'tick') { const bots = (await import('../lib/bot-schedules.mjs')).runBotSchedules(), fired = bots + schedules.runDue(); (await import('../lib/awake.mjs')).refreshAwake(); return fired ? { message: `Queued ${fired} scheduled task${fired === 1 ? '' : 's'}.` } : { quiet: true }; }
67
+ if (words[1] === 'list' || !words[1]) return { schedules: schedules.listSchedules(), message: schedules.listSchedules().map(row => `${row.enabled ? row.when.label : 'paused'} ${row.project} ${row.goal.split('\n')[0]}${row.nextRunAt ? ` next ${schedules.dateText(new Date(row.nextRunAt))}` : ''}`).join('\n') || 'No schedules. In ELU Code, set When in + New task.' };
68
+ refuse(`Unknown command: schedule ${words[1]}. Use elu schedule [list|tick].`);
69
+ }
70
+ if (words[0] === 'conductor') {
71
+ const conductor = await import('../lib/conductor.mjs');
72
+ if (flags.daemon) {
73
+ const owner = new conductor.Conductor({ onExit: () => close() });
74
+ let closing = false; const close = async () => { if (closing) return; closing = true; await owner.stop(); process.exit(0); };
75
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, close);
76
+ owner.start(); return new Promise(() => {});
77
+ }
78
+ if (words[1] === 'stop') {
79
+ if (!conductor.conductorAlive()) return { message: 'The conductor is not running.' };
80
+ conductor.sendCommand({ type: 'shutdown' }); return { message: 'The conductor stops its runs, keeps their history, and exits. It starts again when work is queued.' };
81
+ }
82
+ if (words[1]) refuse(`Unknown command: conductor ${words[1]}. Use elu conductor [stop].`);
83
+ if (flags.json || !process.stdin.isTTY || !process.stdout.isTTY) { const { dashboardSnapshot } = await import('../lib/projects.mjs'); const { boardRows } = await import('../lib/board.mjs'); return { conductor: conductor.conductorStatus(), agents: boardRows(dashboardSnapshot().projects).map(({ task, request, key, order, ...row }) => row) }; }
84
+ const { launchTerminal } = await import('../lib/terminal.mjs'); return launchTerminal();
85
+ }
86
+ if (words[0] === 'peer' && flags.daemon) {
87
+ const { PeerDaemon } = await import('../lib/peer/daemon.mjs');
88
+ const owner = new PeerDaemon({ onExit: () => close() });
89
+ let closing = false; const close = async () => { if (closing) return; closing = true; await owner.stop(); process.exit(0); };
90
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, close);
91
+ await owner.start(); return new Promise(() => {});
92
+ }
93
+ if (words[0] === 'relay') {
94
+ const { startRelay } = await import('../lib/peer/cli.mjs');
95
+ const { relay, result } = await startRelay(flags);
96
+ let closing = false; const close = async () => { if (closing) return; closing = true; await relay.close(); process.exit(0); };
97
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, close);
98
+ process.stdout.write(flags.json ? `${JSON.stringify({ success: true, ...result })}\n` : formatResult(result));
99
+ return new Promise(() => {});
100
+ }
101
+ if (words[0] === 'team' && !words[1] && !flags.json && process.stdin.isTTY && process.stdout.isTTY) { const { launchTerminal } = await import('../lib/terminal.mjs'); return launchTerminal({ team: true, repo: findRepository(process.cwd()) }); }
102
+ if (PEER_COMMANDS.has(words[0])) { const { peerCli } = await import('../lib/peer/cli.mjs'); return peerCli(words, flags); }
103
+ if (words[0] === 'dashboard') {
104
+ if (flags.json) { const { dashboardSnapshot } = await import('../lib/projects.mjs'); return dashboardSnapshot(); }
105
+ const { launchTerminal } = await import('../lib/terminal.mjs'); return launchTerminal();
106
+ }
107
+ if (!words.length && !flags.json && (!process.stdin.isTTY || !process.stdout.isTTY)) return { help: HELP };
108
+ if (!words.length) {
109
+ const repo = findRepository(process.cwd());
110
+ if (flags.json) { const { projectSnapshot, dashboardSnapshot } = await import('../lib/projects.mjs'); return repo ? projectSnapshot(repo) : { welcome: true, directory: process.cwd(), ...dashboardSnapshot() }; }
111
+ const { launchTerminal } = await import('../lib/terminal.mjs'); return launchTerminal({ repo, startDirectory: process.cwd(), askProject: true });
112
+ }
113
+ const repo = repository(process.cwd());
114
+ const command = words[0];
115
+ if (command === 'init') {
116
+ const plan = initPlan(repo);
117
+ if (plan.initialized) return plan;
118
+ return await confirm(plan, flags) ? applyInit(repo, plan) : { ...plan, needsConfirmation: !flags['dry-run'] };
119
+ }
120
+ if (command === 'policy' && words[1] === 'show') {
121
+ const { head } = await import('../lib/git.mjs');
122
+ const { config } = pinnedConfig(repo, head(repo.root));
123
+ // Only the lead entry runs. Bots do the reviews, read-only, whatever an older config lists.
124
+ const role = config.roles.lead; let refusal = null; try { assertPolicy(role); } catch (e) { refusal = e.message; }
125
+ return { roles: [{ name: 'lead', ...policyReport(role), refusal }], unsupportedVendors: unsupportedVendors().map(({ bin, ...value }) => value) };
126
+ }
127
+ if (command === 'run') {
128
+ if (flags.publish) {
129
+ if (flags.yes || flags['dry-run']) refuse('Publication uses its own exact push confirmation.');
130
+ const task = readTask(repo, words[1]); const release = lock(repo, `task-${task.id}`);
131
+ try { return flags['approve-push'] ? await publish(repo, task, flags['approve-push']) : await preparePublish(repo, task); }
132
+ finally { release(); }
133
+ }
134
+ if (flags['approve-push']) refuse('--approve-push requires --publish.');
135
+ // A scripted run picks a mode too; ask needs someone at the terminal to answer.
136
+ if (flags.permissions === 'ask') refuse('Ask needs the terminal app.');
137
+ const session = { ...(flags.permissions !== undefined ? { permissions: flags.permissions } : {}), ...(flags.place !== undefined ? { place: flags.place } : {}), ...(flags.folder ? { folders: flags.folder.map(dir => path.resolve(dir)) } : {}) };
138
+ const release = lock(repo);
139
+ let plan;
140
+ try { plan = await runPlan(repo, words.slice(1).join(' '), session); } finally { release(); }
141
+ if(flags['dry-run'])return{...plan,preview:true,needsConfirmation:false,message:'Review this task preview. Nothing ran and no model credit was used.'};
142
+ const approved = await confirm(plan, flags);
143
+ if (!approved) return { ...plan, needsConfirmation: true };
144
+ const task = await runTask(repo, plan);
145
+ return { ...task, draftPrBody: prBody(task), published: false, message: 'Local work finished. Use run <id> --publish to review the exact commit and push destination.' };
146
+ }
147
+ if (command === 'status') {
148
+ let values = words[1] ? [readTask(repo, words[1])] : tasks(repo);
149
+ if (flags.recover) {
150
+ values = values.map((task) => {
151
+ const release = lock(repo, `task-${task.id}`);
152
+ try {
153
+ const file = path.join(taskDir(repo, task.id), 'process.json');
154
+ const record = fs.existsSync(file) ? readJson(file) : null;
155
+ if (task.state !== 'running' && !record?.pid) return task;
156
+ if (record) recoverProcess(record);
157
+ task.state = 'blocked'; task.error = { message: 'The controlling process ended. Review the worktree and rerun checks; nothing was restarted.' }; saveTask(repo, task); return task;
158
+ } finally { release(); }
159
+ });
160
+ }
161
+ return { tasks: values.map(({ goal, runs, responses, reviews, ...task }) => ({ ...task, goal, runs: runs.map(({ logFile, ...run }) => run), responses, reviewRounds: reviews.length })) };
162
+ }
163
+ if (command === 'check') {
164
+ const task = readTask(repo, words[1]); const release = lock(repo, `task-${task.id}`);
165
+ try { return await runChecks(repo, task); } finally { release(); }
166
+ }
167
+ if (command === 'open') {
168
+ // --bot opens what one of the session's bots saved. --role still reads a name an earlier run saved under.
169
+ const task = readTask(repo, words[1]); const bot = flags.bot === undefined ? null : String(flags.bot).replace(/^@/, '');
170
+ const role = bot !== null ? botKey(bot) : flags.role ?? 'lead';
171
+ const session = Object.hasOwn(task.sessions, role) ? task.sessions[role] : null;
172
+ if (!session || !/^[a-f0-9-]{36}$/i.test(session.id)) refuse(bot !== null ? `@${bot} has no saved session in task ${task.id}.` : 'There is no saved session to open.');
173
+ if (task.state === 'running') refuse('Wait for the headless run to finish before opening its session.');
174
+ // The adapter names its resume command; a vendor with a private HOME names it in env.
175
+ const adapter = ADAPTERS[session.vendor];
176
+ const plan = adapter?.interactive?.({ session: session.id, cwd: task.worktree }) ?? { bin: session.vendor, args: session.vendor === 'codex' ? ['resume', session.id] : ['--resume', session.id] };
177
+ const prefix = Object.entries(plan.env ?? {}).map(([key, value]) => `${key}=${quote(value)} `).join('');
178
+ const info = { command: `cd ${quote(task.worktree)} && ${prefix}${plan.bin} ${plan.args.map(quote).join(' ')}`, message: "Opening uses the vendor's interactive CLI and your own approvals. ELU Code's headless policy does not apply." };
179
+ if (flags.exec) {
180
+ if (flags.json || !process.stdin.isTTY) refuse('Interactive opening needs a terminal. Use the printed command.');
181
+ process.stdout.write(`${info.message}\n`);
182
+ const { spawnSync } = await import('node:child_process');
183
+ const result = spawnSync(plan.bin, plan.args, { cwd: task.worktree, env: plan.env ? { ...process.env, ...plan.env } : process.env, stdio: 'inherit' });
184
+ return { ...info, exitCode: result.status };
185
+ }
186
+ return info;
187
+ }
188
+ if (command === 'uninstall') {
189
+ const plan = uninstallPlan(repo);
190
+ return await confirm(plan, flags) ? applyUninstall(repo, plan) : { ...plan, needsConfirmation: !flags['dry-run'] };
191
+ }
192
+ refuse(`Unknown command: ${words.join(' ')}. Use elu --help.`);
193
+ }
194
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
195
+ try {
196
+ const result = await main();
197
+ if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify({ success: true, ...result })}\n`);
198
+ else if (result.help) process.stdout.write(result.help);
199
+ else if (process.argv.includes('--version')) process.stdout.write(`${result.version}\n`);
200
+ else if (result.quiet) { /* Nothing to report. */ }
201
+ else process.stdout.write(formatResult(result,process.argv[2]));
202
+ if (result.results?.some((row) => row.status !== 'passed') || result.state === 'blocked'||result.sandboxHelper?.working===false||result.success===false) process.exitCode = 1;
203
+ } catch (error) {
204
+ const output = { success: false, message: error.message, ...error.details };
205
+ if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify(output)}\n`);
206
+ else process.stderr.write(`${error.message}\n${error.details&&Object.keys(error.details).length?Object.entries(error.details).map(([key,value])=>`${key}: ${typeof value==='object'?JSON.stringify(value):value}`).join('\n')+'\n':''}`);
207
+ process.exitCode = 1;
208
+ }
209
+ }
package/bin/elu.cjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // The first file every start runs. It is written for any Node to read, so an old Node gets
3
+ // one sentence here and not a syntax error. What only a current Node can read is in start.cjs.
4
+ 'use strict';
5
+ var MINIMUM = 22;
6
+ if (!(parseInt(process.versions.node, 10) >= MINIMUM)) {
7
+ process.stderr.write('ELU Code needs Node ' + MINIMUM + ' or newer; this is Node ' + process.versions.node + '.\n');
8
+ process.exit(1);
9
+ }
10
+ require('./start.cjs');
package/bin/start.cjs ADDED
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+ // Loaded by elu.cjs once the Node is new enough to read this line.
3
+ import('../lib/install.mjs').then(function (install) { return install.start(); }).catch(function (error) {
4
+ process.stderr.write(String((error && error.stack) || error) + '\n');
5
+ process.exit(1);
6
+ });