@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
@@ -0,0 +1,131 @@
1
+ # Antigravity CLI
2
+
3
+ `agy` is Google's terminal coding agent for Google One AI Pro and Ultra subscribers (Plus
4
+ and Free with a quota). In ELU Code it can run sessions and reviews, in every permission mode. `agy`
5
+ runs only after it passes the one-time check below on this machine.
6
+
7
+ ## Install
8
+
9
+ Use the vendor's installer from [antigravity.google/docs](https://antigravity.google/docs).
10
+ It puts `agy` in your user bin folder (`~/.local/bin`); nothing needs sudo. ELU Code never
11
+ installs it and never updates it: its own runs set `AGY_CLI_DISABLE_AUTO_UPDATE=true`, your
12
+ own runs keep the vendor's default.
13
+
14
+ ## Sign in
15
+
16
+ Run `agy` once and sign in with your Google account. The token lives in the OS keychain
17
+ (on Linux, the keyring reached over `DBUS_SESSION_BUS_ADDRESS`, which ELU Code passes
18
+ through). ELU Code checks sign-in with `agy -p /usage --output-format json`: exit code only,
19
+ no model turn, no quota spent. Signed out, ELU Code says: `Antigravity: not signed in. Run: agy`.
20
+
21
+ Google's FAQ at [antigravity.google/docs/faq](https://antigravity.google/docs/faq) says,
22
+ verbatim: "Using third-party software, tools, or services to access Antigravity is a
23
+ violation of our Terms of Service and severely degrades the experience for legitimate
24
+ product users. Such actions can result in suspension or termination of your account…" and,
25
+ on headless use: "Antigravity CLI headless mode (`agy -p`) is intended for direct scripting
26
+ and CI workflows. Wrapping `agy` inside third-party coding agents or applications, or
27
+ automating account rotation, is not supported under consumer plans." ELU Code wraps `agy`.
28
+ Enabling it is your decision; the risk is your account, and ELU Code cannot reduce it.
29
+
30
+ ## The one-time check
31
+
32
+ The check proves, on this machine and for the exact binary and release found, that the
33
+ enforcement designed from Google's documentation holds. ELU Code starts it by itself
34
+ before your first message to Antigravity goes out, whether you wrote to a session on it or
35
+ to a bot on it. The session says so first, then shows each step as it ends, and then your
36
+ message goes on. The check takes about seven short turns on Antigravity and a few minutes.
37
+ Only a message you just sent starts it: a scheduled turn, a shadow turn or a review never
38
+ does. It runs in a throwaway repository with a planted file outside it, a local web
39
+ listener and a marker command:
40
+
41
+ | Step | Proves |
42
+ | --- | --- |
43
+ | `signin` | the sign-in probe passes under ELU Code's private HOME |
44
+ | `locked-no-write` | a read-only run told to create files creates nothing, inside or outside |
45
+ | `locked-no-shell` | told to run the marker command, the marker never runs |
46
+ | `locked-no-network` | told to fetch the local URL, the listener sees nothing |
47
+ | `json` | headless output parses and the result matches the schema |
48
+ | `resume` | a second run with the conversation id continues the first |
49
+ | `relay` (optional) | over `agy acp`, a write outside the workspace arrives as a permission request, and a denial writes nothing |
50
+
51
+ The result is saved under your ELU Code state. A different binary path or release has to
52
+ pass again, on your next message to it; until it does, no run starts, and a turn you did
53
+ not send yourself says
54
+ `Antigravity CLI has not been checked on this machine yet. Your first message to it runs the check.`
55
+ If a required step fails, Antigravity stays off here, the session says which step was not
56
+ proven, and your next message to it runs the check again. When `relay` fails,
57
+ Ask and Auto still run: blocked actions are denied and reported instead of asked live, and
58
+ the permissions picker says so.
59
+
60
+ ## The private HOME
61
+
62
+ `agy` reads `~/.gemini` for settings, hooks, MCP servers, plugins, skills and context files,
63
+ and has no flag to turn that off. So ELU Code runs it with `HOME` set to a folder under its
64
+ own state (`agy-home/`, mode 0700), one per workspace, holding only:
65
+
66
+ - `.gemini/antigravity-cli/settings.json`: the mode's permission rules, written before each run;
67
+ - `.gemini/config/hooks.json`: one PreToolUse hook, `lib/agy-hook.mjs`, run with the mode's name;
68
+ - the conversations `agy` keeps there, so a session resumes with `--conversation`, also
69
+ after `/permissions` changed its mode: the rules are rewritten for every run.
70
+
71
+ A mode switch applies from the next turn: the same conversation continues under the
72
+ workspace HOME, whose settings and hook are rewritten for the new mode before the run.
73
+
74
+ Nothing in your real `~/.gemini` reaches a run: not your settings, hooks, MCP servers,
75
+ plugins, skills, `GEMINI.md` or `AGENTS.md`. Context files inside the workspace
76
+ (`AGENTS.md`, `GEMINI.md`, `.agents/rules/*.md`) do reach the prompt; `agy` has no flag to
77
+ skip them, so a repository can steer the model. `.agents` is a protected path, so the model
78
+ cannot add such files itself.
79
+
80
+ The prompt goes on standard input, never on the command line, so other users on the
81
+ machine cannot read it with `ps`.
82
+
83
+ ## What each mode enforces
84
+
85
+ | | Read only | Ask for approval | Approve for me | Full access |
86
+ | --- | --- | --- | --- | --- |
87
+ | How it runs | `agy -p --sandbox` | `agy acp` after `relay` passed, else `-p` | `agy acp` after `relay` passed, else `-p --mode=accept-edits --sandbox` | `-p --dangerously-skip-permissions` |
88
+ | Settings | `request-review`; `command`, `write_file`, `read_url`, `execute_url`, `mcp` denied; workspace only | `request-review`; `command`, `read_url`, `execute_url`, `mcp` ask; workspace only; protected files and secret folders denied to the file tool | `proceed-in-sandbox`; `sudo` and `.git` denied; workspace only; protected files and secret folders denied | outside access allowed; `read_url`, `execute_url`, `mcp` allowed; secret folders denied to the file tool |
89
+ | Hook | denies web search, commands, edits, image generation, scheduling and subagents | asks for web search; denies push, tag, merge, deploy and publish by command text | denies web search and the never list; sends the rest to the reviewer | denies the never list by command text |
90
+ | Named folders | none | `--add-dir` each; `write_file` denied in each | same | `--add-dir` each |
91
+ | Web | denied | asks, or reported | reviewed, or reported | allowed |
92
+
93
+ A named folder is read and never written in Ask for approval and Approve for me: its
94
+ `write_file` deny is the one a protected file gets. A folder that holds the workspace or
95
+ lies inside it gets no deny, because a deny wins over the workspace's edits: for example
96
+ the folder of all your projects, where a worktree sits beside its repository.
97
+
98
+ In headless mode a blocked action is a soft deny: the run goes on, exits 0, and prints a
99
+ notice on stderr naming the tool. ELU Code records each notice as a denial on the run and
100
+ the model is told to return `needs_input` naming what it needs. Over ACP, a permission
101
+ request reaches you under Needs you; no answer within 20 minutes counts as a denial.
102
+
103
+ ## What is not enforced, or not verified
104
+
105
+ Everything above comes from Google's documentation and the Antigravity changelog; the
106
+ one-time check is the proof, per machine. In particular, until a check says otherwise:
107
+
108
+ - the never list is matched on command text by the hook, a tripwire that an obfuscated
109
+ command gets past; in Full access it is prompt text and that hook only;
110
+ - stand-in commands first on the session's PATH also refuse the never list by name for
111
+ what a script runs. That was seen only in tests against a stand-in `agy`, not with the
112
+ real one;
113
+ - whether `read_url` denies cover web search (hence the hook), whether the hook loads from
114
+ the private HOME, and the exact shape of the headless notice and of the ACP permission
115
+ request;
116
+ - whether `-p` in a folder it does not trust refuses instead of denying softly, and whether
117
+ the keychain sign-in is visible under the private HOME;
118
+ - that `-p` reads the task from standard input, that `--print-timeout` takes seconds
119
+ (`900s`), and that the output names the conversation id (the `json` and `resume` steps);
120
+ - secret reads: `agy` has no read deny, so a read of a secret folder is stopped only by
121
+ workspace-only access (Read only, Ask, Approve for me); Full access can read anything;
122
+ - whether a command can write in a folder given with `--add-dir`: the `write_file` deny
123
+ covers the file tool;
124
+ - on Linux, `--sandbox` needs unprivileged user namespaces; without them Read only and
125
+ Approve for me report instead of sandboxing.
126
+
127
+ ## Telemetry
128
+
129
+ Antigravity's "Interactions data" (prompts and responses used to improve the product) is an
130
+ account setting, not a file or an environment variable. ELU Code cannot turn it off; opt out
131
+ in your Antigravity account settings.
@@ -0,0 +1,124 @@
1
+ # Claude Code
2
+
3
+ Sessions and reviews. Sign-in is `claude`'s own; ELU Code never reads your sign-in
4
+ token. The Usage tab names the account from `~/.claude.json` (email, organization,
5
+ plan and rate limit tier) and shows only the five-hour and weekly usage windows that
6
+ Claude Code prints during sessions. Tested releases:
7
+ 2.1.319 and 2.1.322. The modes are described in
8
+ [../permissions.md](../permissions.md); this page is how `claude` is launched
9
+ for each.
10
+
11
+ ## Read only
12
+
13
+ The launch: `--restricted --permission-mode dontAsk --permission-prompts none`,
14
+ `--safe-mode` unless your own MCP servers are on (it would drop them), an explicit
15
+ `--tools` list with no shell unless the role declares command prefixes, strict MCP
16
+ configuration (none for a review, a read-only turn or a teammate's question; for a task
17
+ saved without a mode, your own Claude Code servers, all of them unless `/mcp off NAME`
18
+ turned one off for the project), no setting sources,
19
+ disabled hooks, the OS sandbox with network off, and
20
+ `blockReadsOutsideWorkingDirectories`. An answer turn in a session saved without a
21
+ mode also gets `--add-dir` for your home folder and for a private copy of your Claude
22
+ Code memory, when there is one.
23
+
24
+ ## Ask for approval, Approve for me, Full access
25
+
26
+ | | Ask | Auto | Full |
27
+ | --- | --- | --- | --- |
28
+ | `--permission-mode` | `default` | `auto` | `bypassPermissions` |
29
+ | `--permission-prompts` / `--permission-prompt-tool` | `host` / `mcp__elu-code-approval__approve` | same | same |
30
+ | `--restricted` / `--safe-mode` | no | no | no |
31
+ | `--tools` | `Read,Grep,Glob,Edit,Write,Bash,WebFetch,WebSearch` | same | same |
32
+ | `--allowedTools` | your MCP servers, `Read`, `Grep`, `Glob`, `Edit` and `Write` on the workspace | same | none |
33
+ | `--disallowedTools` | `Agent,Task,NotebookEdit,PowerShell,Monitor`; a turn with helpers, below | same | same |
34
+ | `--add-dir` | each folder of the session; an answer turn (yours, or a working bot's) also gets your home folder and a private copy of your Claude Code memory when there is one | same | same |
35
+ | Sandbox | on where it starts, never required, no network; unsandboxed commands ask you | on where it starts, never required, no network; unsandboxed commands go to the reviewer | off |
36
+ | Asks | web tools; answer turns also edits in the project folder | answer-turn edits only | none |
37
+ | Denies | protected paths, writes in the folders of the session, secret reads, ELU Code state, the never list | same, without dependency files | secret reads, ELU Code state, the never list |
38
+
39
+ Every profile keeps `--setting-sources ''`, `--strict-mcp-config` with a private
40
+ config file and disabled hooks. `--max-budget-usd` is passed only when the session
41
+ has a spending limit (`/budget`) or the run is a teammate's question. The approval relay is the
42
+ one server ELU Code adds to that file; it is never in the allow list.
43
+
44
+ A folder you name with `/permissions folders add` is read and never written in Ask and
45
+ Auto: `Edit` and `Write` deny rules cover it and the sandbox's `denyWrite` lists it, the
46
+ way protected paths are kept. Claude Code lets a deny win over an allow, so a folder that
47
+ holds the workspace or lies inside it gets neither: for example the folder of all your
48
+ projects, where a worktree sits beside its repository. Nor does a folder that holds the
49
+ cache folder where the run keeps its temporary files (below). Not seen with the real
50
+ service yet.
51
+
52
+ The never list as deny rules: `git push`, `git tag`, `git merge`, `gh pr merge`,
53
+ `gh release`, `npm publish`, `pnpm publish`, `yarn publish`, `cargo publish`,
54
+ `firebase deploy`, `vercel deploy`, `gcloud run deploy`, `gcloud app deploy`,
55
+ `fly deploy`, `kubectl apply`, `terraform apply`. They match command text, so
56
+ `git -C . push` or a shell wrapper gets past them; in Ask the sandbox has no
57
+ network and the common directory is read-only, which is what holds. A bot that is
58
+ allowed to merge pull requests (`@elu` comes allowed; a bot you make is not) runs its
59
+ own working turn without the `gh pr merge` rule; in Ask that command asks you instead.
60
+
61
+ A working bot's turn that may start helpers (see [../permissions.md](../permissions.md))
62
+ is launched with these changes: `--tools` adds `Agent`; `--allowedTools` adds
63
+ `Agent(executor)`, except in Full; `--disallowedTools` is
64
+ `NotebookEdit,PowerShell,Monitor`; `--agents` defines the one helper, `executor`, with
65
+ the turn's own tools and the helpers' model; the deny rules add each built-in agent type
66
+ and an `Agent` call that names a model or an isolation; and the settings and the
67
+ environment turn off built-in agents, forked agents, helpers of helpers and background
68
+ tasks, and force the helpers' model when the bot names one.
69
+
70
+ Stand-in commands (`lib/never-shims.mjs`) cover what those rules cannot read: a
71
+ script, `sh -c`, `npm test`. Claude Code starts each command from a snapshot of
72
+ your shell setup, which builds PATH again, so a folder put first on the PATH it was
73
+ started with ends up behind the system's. ELU Code sets `CLAUDE_ENV_FILE` to a
74
+ one-line file that puts the folder first again; Claude Code runs that file ahead of
75
+ each command. Seen on 2.1.322 in Ask for approval and in Approve for me: `npm test`
76
+ whose script ran `git push` to an origin on another machine printed `git push is
77
+ never allowed, in any mode.` and the push exited 126, and the run kept the refusal
78
+ for the chat; the same turn without the file reached `/usr/bin/git`. Not seen with
79
+ the real service: Full access.
80
+
81
+ Claude Code keeps files of its own while it runs. After every command it writes a
82
+ note of its working folder, and its sandboxed commands are given the same folder as
83
+ `TMPDIR`. Its own place for them is `/tmp/claude-<uid>`, and the sandbox ELU Code
84
+ sets for Read only, Ask for approval and Approve for me denies writes under `/tmp`,
85
+ so every sandboxed command ended `Exit code 1` whatever it did. In those modes ELU
86
+ Code names a private folder of the run in `CLAUDE_CODE_TMPDIR`:
87
+ `<cache folder>/elu/<two letters>`, yours alone to read, made for the run and
88
+ removed when it ends. The cache folder is `XDG_CACHE_HOME`, or `~/.cache`. Claude
89
+ Code uses a named folder for sandboxed commands only while `<folder>/claude-<uid>`
90
+ is at most 44 bytes. Where the cache folder is too long for that (a home folder of
91
+ more than nineteen bytes, with a three-digit user number) your own runtime folder
92
+ is tried, `XDG_RUNTIME_DIR` (`/run/user/<uid>` on Linux): the run's folder there is
93
+ `<runtime folder>/elu<two letters>`, which fits a ten-digit user number. Where
94
+ neither fits none is named, and commands there still end 1. Seen on 2.1.322 in Ask
95
+ for approval: `npm test` ended 0 with `TMPDIR` in the cache folder's place, and the
96
+ folder was gone after the run. Not seen with the real service: Approve for me, Read
97
+ only, Linux, the runtime folder.
98
+
99
+ A sandboxed command reads the repository's Git folder. The sandbox's `denyRead`
100
+ holds only ELU Code's records in it (`<Git folder>/elu-code`) and `denyWrite` holds
101
+ the whole folder; a teammate's question keeps the whole folder in `denyRead`. Seen
102
+ on 2.1.322 in Ask for approval, in an answer turn: `git status`, `git diff` and
103
+ `git log` ran in the sandbox with no approval asked; `git commit` ended 128 on
104
+ `index.lock: Operation not permitted`; `ls .git/elu-code` was refused. A text
105
+ filter named in the Git settings ran inside the sandbox when `git diff` used it:
106
+ it could write nothing, and its call to the network was refused. Not seen with the
107
+ real service: Approve for me, Read only, an edit turn in a worktree, Linux.
108
+
109
+ `parse` reads the profile Claude ran with from its init line. A run asked for
110
+ Auto that reports `default` is shown as Ask. Denied tool calls come back on the
111
+ result as `permission_denials` and are kept on the run.
112
+
113
+ On Linux, Claude Code's shell sandbox needs `bwrap` and `socat` on PATH, and `bwrap`
114
+ must be able to start (Ubuntu 24.04 needs an AppArmor profile for it; see Linux in
115
+ [permissions.md](../permissions.md)). Where the sandbox does not start, Claude Code
116
+ runs commands without one: Ask for approval asks you for each, and Approve for me
117
+ sends each to the reviewer. The Permissions picker says "No OS sandbox here" when a
118
+ tool is missing. Where both are installed and `bwrap` cannot start, Claude Code says
119
+ its sandbox is disabled; seen on 2.1.322, `npm test` then came to the owner as an
120
+ approval. Nothing is installed for you.
121
+
122
+ Not enforced or not verified on these releases: Bash rules match text only;
123
+ WebFetch runs outside the sandbox; a `bypassPermissions` run does not check
124
+ protected paths; whether Claude's auto reviewer counts toward the dollar budget.
@@ -0,0 +1,64 @@
1
+ # Codex
2
+
3
+ Sessions and reviews. Tested release: 0.166.0-alpha.5.1. Subscription: ChatGPT.
4
+
5
+ Labels: **[run]** checked by running the tested release of `codex` against a local mock model server (the real backend was never called); **[docs]** from OpenAI's documentation or `codex exec --help`; **[unverified]** neither.
6
+
7
+ ## Install and sign in
8
+
9
+ - `npm i -g @openai/codex` (a user prefix, no sudo).
10
+ - `codex login` and choose ChatGPT. ELU Code reads `codex login status` and accepts only `Logged in using ChatGPT` [run]. An API key sign-in exits 0 too, so it is reported as signed out and the status line says `Codex: sign in with ChatGPT. Run: codex login`. No key variable is read or passed.
11
+ - If another `codex` shim comes first on your PATH (the cmux shim does on some machines), put the real binary first. ELU Code launches with `features.hooks=false`; whether that neutralizes a shim's hooks is [unverified].
12
+
13
+ ## What each mode launches
14
+
15
+ Every mode keeps the same isolation: `--ignore-user-config --ignore-rules --json`, `-c mcp_servers={}`, hooks, apps, plugins, browser, multi-agent, image and skill features off, user input and plan tools off, and a refusal to launch when a project or managed `config.toml` could add MCP servers. The output schema goes through `--output-schema`; the prompt arrives on stdin. A session's model also gets `--skip-git-repo-check`: a project folder need not be a Git repository, and without the flag Codex refuses to start there [run].
16
+
17
+ | | Read only (reviewer) | Ask for approval | Approve for me | Full access |
18
+ |---|---|---|---|---|
19
+ | Sandbox | `--sandbox read-only` | profile `elu_ask` (below) [run] | the same profile [run] | `-c sandbox_mode="danger-full-access"` [run] |
20
+ | Approvals | `never` | `never`; a blocked action produces no stream item [run] | `on-request` with `approvals_reviewer="auto_review"` [run] | `never` |
21
+ | Shell | none | `exec_command` inside the sandbox [run] | same; an escalation goes to the reviewer model [run] | any command, no sandbox [run] |
22
+ | Network | blocked | blocked, loopback included [run] | blocked unless the reviewer allows an escalation | open [run] |
23
+ | Web search | off | off | off | `web_search="live"` [run: the tool is offered; a real search is unverified] |
24
+ | Named folders | n/a | read-only | read-only | n/a |
25
+ | Where | task worktree | worktree or branch | worktree or branch | also your checkout |
26
+
27
+ The `elu_ask` profile is one `-c permissions.elu_ask=…` value with `default_permissions="elu_ask"` [run]:
28
+
29
+ - `extends=":workspace"` for edit turns: the workspace is writable, everything else readable [run]. Answer turns use `extends=":read-only"` [run].
30
+ - `":slash_tmp"="read"` and `":tmpdir"="read"`: the shared temp folders the workspace sandbox adds are read-only again [run]. The `sandbox_workspace_write.exclude_*` keys do nothing under a profile [run], so they are not passed.
31
+ - Each named folder `"<dir>"="read"`: readable, not writable, the same entry a protected file gets below.
32
+ - Each protected file `"<path>"="read"`: readable, not writable [run]. The structural checks still run afterwards.
33
+ - Home secrets, ELU Code's state folder and the repository's `.git/elu-code` as `"<path>"="deny"`: a shell `cat` or `ls` there never starts and no item is reported [run]. The reviewer model in Approve for me sees the same deny list in its context [run].
34
+
35
+ A profile value containing `*`, `?`, `[`, `]`, `{` or `}` is refused before launch: Codex reads those as patterns.
36
+
37
+ Resume: `codex exec resume <thread_id> -` with the same `-c` set. `exec resume` has no `--sandbox` or `--cd`, so the sandbox and profile come from `-c` keys and the process runs in the workspace [run: the profile, Approve for me and Full access all applied on a resumed thread].
38
+
39
+ ## What the owner sees
40
+
41
+ - Approve for me: an action the reviewer declines comes back as `status: "declined"` and is listed on the run as a denial (`Run: <command>` or `Edit: <paths>`) [run]. The model is told not to retry it and to return `needs_input`.
42
+ - Ask for approval: Codex has no live approval relay. A sandbox block produces no stream item and no denial record [run]; the model reports what it could not do.
43
+ - No dollar figure: Codex reports no cost in its stream. `maxCostUsd` must be `null`; the time limit bounds the run.
44
+
45
+ ## Code mode, seen with the real service
46
+
47
+ These lines come from real turns on 0.166.0-alpha.5.1 with a ChatGPT sign-in, not from the mock model server.
48
+
49
+ - Every model in the release's catalog but one takes its tools only through the code-mode host (`"tool_mode": "code_mode_only"` in `codex debug models --bundled`). The mock server knows no such model, so the [run] checks above never met it.
50
+ - With `features.code_mode_host=false` a turn opens with `Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed`. No command and no file read starts; the model can only ask you to enable shell execution. A session therefore leaves `code_mode_host` at the release's default. A reviewer has no tools and keeps it off.
51
+ - With the host on, in Ask for approval and in Approve for me: a command runs as `/bin/zsh -lc '…'` and arrives as a `command_execution` item, a patch as a `file_change` item, and the adapter reads the stream. No other kind of item appeared.
52
+ - The sandbox still refused: a write beside the workspace (`Operation not permitted`), a patch to a protected file (rejected, no item, no denial record) and a host lookup by `curl`.
53
+ - The never list: with `allow_login_shell=false` a command runs as `/bin/zsh -c '…'`, and `command -v git` inside the sandbox names the stand-in. In Ask for approval, `npm test` whose script ran `git push` to an origin on another machine (an `ssh://` URL to a name that does not resolve) printed `git push is never allowed, in any mode.`, the push exited 126, ssh was never started, and the run kept the refusal for the chat. The same script's push to a bare repository inside the project went to the real git, and the sandbox of the mode refused its write (`remote unpack failed`). Approve for me was seen with the same refusal before local pushes were let through. With a login shell (`-lc`) the stand-in folder lands behind `/usr/bin` on macOS and would refuse nothing. Asked to run `git push` itself, the model declined, citing its instructions.
54
+ - After a refused step the model answers `needs_input`, names what it needs in the summary and leaves `question` empty (three turns of three). ELU Code reads that reply as it is. Refusing it started a second session that ran the turn's commands again.
55
+ - Not seen: Full access with the real service, a declined escalation in Approve for me, and what a code-mode cell can do without calling a tool.
56
+
57
+ ## Not enforced or not verified
58
+
59
+ - No per-command allow or deny rules (`--ignore-rules`). In every mode but Read only the never list is held by stand-in commands first on the session's PATH (`lib/never-shims.mjs`), and `-c allow_login_shell=false` keeps a login shell from rebuilding PATH with the system folders ahead of them. They refuse by name and are not a sandbox: a full path to the real command, a changed PATH, a Git alias or another program that does the same work gets past them. A `git push` to a folder on this machine and a `git merge` or `git tag` in a scratch repository under the temporary directory pass, since a project's own tests do both. Behind them, in Ask the sandbox has no network and the Git common directory is read-only; in Approve for me an escape depends on the reviewer model; in Full access there is nothing.
60
+ - Reads are open everywhere except the profile's deny entries. Glob deny entries such as `**/.env*` are [unverified].
61
+ - Approve for me: an allowed escalation ran a write outside the sandbox [run]; an allowed escalated `curl` to a loopback address still could not connect [run, cause unknown]. An allowed escalation to an external host reached it in an earlier run.
62
+ - Full access web search: the `web_search` tool is offered with `web_search="live"` [run]; no search was performed against the real service.
63
+ - Whether `features.hooks=false` stops a PATH shim's hooks [unverified].
64
+ - Linux: the tested release runs its sandbox on bubblewrap, with its own copy of `bwrap` (Landlock is its older option). The profile semantics above were checked on macOS only; the Linux tests run with `bubblewrap` installed and an AppArmor profile loaded ([../permissions.md](../permissions.md#linux)).
@@ -0,0 +1,151 @@
1
+ # Grok Build
2
+
3
+ Grok Build (`grok`) is xAI's coding CLI for SuperGrok and X Premium+ subscribers. ELU Code
4
+ runs sessions on it. Its enforcement follows the vendor's documentation; a one-time check on
5
+ your machine proves it before the first run.
6
+
7
+ ## Install and sign in
8
+
9
+ ```
10
+ npm i -g @xai-official/grok
11
+ grok login
12
+ ```
13
+
14
+ Install into a user-writable npm prefix; nothing here needs sudo. `grok login` opens the
15
+ browser (`grok login --device-auth` on a remote machine) and keeps the token in
16
+ `~/.grok/auth.json`. ELU Code never reads that file, never copies it, and reads no API key
17
+ variable: if `grok` is signed out, the session says `Grok Build: not signed in. Run: grok login`.
18
+
19
+ ## The one-time check
20
+
21
+ ELU Code starts the check by itself before your first message to Grok goes out, whether
22
+ you wrote to a session on it or to a bot on it. The session says so first, then shows each
23
+ step as it ends, and then your message goes on. The check takes about seven short turns on
24
+ Grok and a few minutes. Only a message you just sent starts it: a scheduled turn, a shadow
25
+ turn or a review never does.
26
+
27
+ The check runs eight steps against the installed binary in a throwaway repository, with a
28
+ planted file outside it, a local web listener and a marker command:
29
+
30
+ | Step | What it proves |
31
+ |---|---|
32
+ | `signin` | the token works from ELU Code's private Grok home |
33
+ | `locked-no-write` | a read-only run told to create files creates none, inside or outside |
34
+ | `locked-no-shell` | told to run the marker command, it never runs |
35
+ | `locked-no-network` | told to fetch the local URL, the listener sees nothing |
36
+ | `json` | the headless result parses and follows ELU Code's schema |
37
+ | `resume` | a second run with the session id continues the first |
38
+ | `relay` | over ACP, a write outside the workspace asks first; denied, nothing is written |
39
+ | `isolation` | `grok inspect --json` shows no Claude or Cursor instructions, hooks or servers |
40
+
41
+ Every step but `relay` must pass. The result is stored per binary and version; a new
42
+ release proves itself again, on your next message to it. Until the check passes, no Grok
43
+ run starts, and a turn you did not send yourself says
44
+ `Grok Build has not been checked on this machine yet. Your first message to it runs the check.`
45
+ If a required step fails, Grok stays off here, the session says which step was not proven,
46
+ and your next message to it runs the check again. If `relay` fails, Ask and
47
+ Auto still work, but a blocked action is reported instead of asked:
48
+ `Grok can't ask live here. Blocked actions are reported.`
49
+
50
+ ## How each mode runs
51
+
52
+ | Mode | Transport | Permissions |
53
+ |---|---|---|
54
+ | Read only | headless (`--prompt-file`) | `--permission-mode dontAsk`, `--sandbox read-only`, tools `read_file,grep,list_dir`, every write, shell, web and MCP tool denied |
55
+ | Ask | ACP (`grok agent stdio`) when `relay` passed, else headless `dontAsk` | `Read` and `Grep` with no path, workspace edits and reads of each named folder allowed by rule; outside edits, named folders included, shell and web ask you (ACP) or are denied and reported (headless); `--sandbox workspace` in headless mode |
56
+ | Auto | ACP with `autoMode`, else headless `--permission-mode auto` | Grok's own reviewer model decides routine actions; what it refuses asks you over ACP |
57
+ | Full | headless `--permission-mode bypassPermissions`, `--sandbox off` | anything; the never list stays as deny rules |
58
+
59
+ Every profile denies reads of secret folders (`~/.ssh`, `~/.aws`, `~/.claude`, `~/.codex`,
60
+ `.env*`, `auth.json`, key files, ELU Code's own state) by rule, and denies the never list on
61
+ command text (`git push`, `git tag`, `git merge`, `gh pr merge`, `gh release`, package
62
+ publishes, deploys, `kubectl apply`, `terraform apply`). Those rules match text: `sh -c`
63
+ and aliases get past them, so in Auto and Full they are a tripwire, not a boundary.
64
+ Stand-in commands first on the session's PATH also refuse the list by name for what a
65
+ script or `sh -c` runs; that was seen only in tests against a stand-in `grok`, not with the
66
+ real one, and whether Grok's sandbox lets a command read that folder is not proven. The
67
+ Read only profile has no shell, so there the never list holds. Grok's sandbox blocks a
68
+ child process's network on Linux only; on macOS the read-only profile holds because it has
69
+ no shell and no web tools, not because the sandbox blocks the network.
70
+
71
+ Headless runs have no schema flag, so ELU Code puts the output schema in the prompt and
72
+ validates the answer. Sessions resume with `-r`; a mode switch that changes the sandbox
73
+ profile cannot resume (Grok keeps a session's sandbox), so that turn's retry starts a fresh
74
+ session without the earlier context. The time limit is the role's; `--max-turns` (40 for
75
+ read-only turns, 120 for edit turns) bounds the model rounds. Cost is not reported under a
76
+ subscription; the role's `maxCostUsd` must be `null`.
77
+
78
+ ## The private home
79
+
80
+ By default Grok reads your `~/.grok` tree (config, rules, hooks, skills, agents,
81
+ `AGENTS.md`), `/etc/grok`, the workspace's `.grok`, every `AGENTS.md` and `CLAUDE.md` up
82
+ the tree, `.envrc`, and your Claude Code configuration: `~/.claude/settings.json` and
83
+ `settings.local.json` (hooks and permission rules), `~/.claude/CLAUDE.md`,
84
+ `~/.claude/rules/*.md`, `~/.claude/agents/*.md`, `~/.claude/skills/`, `~/.claude.json`
85
+ (MCP servers), plus the same Cursor files. The switches
86
+ `GROK_CLAUDE_{SKILLS,RULES,AGENTS,MCPS,HOOKS}_ENABLED=0` and `GROK_CURSOR_*_ENABLED=0`
87
+ exist, and ELU Code sets all ten, but
88
+ [xai-org/grok-build-plugin-cc#34](https://github.com/xai-org/grok-build-plugin-cc/issues/34)
89
+ (open, filed against CLI 1.0.5) reports `~/.claude/CLAUDE.md`, `rules` and `agents` still
90
+ loading with every switch off, and the `~/.claude` scan is anchored on `HOME`, which
91
+ `GROK_HOME` does not move.
92
+
93
+ So every run gets a private `HOME` under ELU Code's state (`grok-home` in your personal
94
+ directory) and a `GROK_HOME` inside it holding only what ELU Code wrote:
95
+
96
+ - `config.toml`: Claude and Cursor compatibility off, telemetry, trace upload, Mixpanel,
97
+ feedback, managed config, campaigns, remote fetch and server-side search off, workspace
98
+ snapshots and codebase uploads off, auto-update off, session relay off, `.envrc` off,
99
+ subagents off, and the never list as deny rules. It is rewritten before every run.
100
+ - `trusted_folders.toml`: empty. A workspace is never trusted, so its `.grok/config.toml`,
101
+ hooks, MCP servers and skills never load. Its `AGENTS.md` and `CLAUDE.md` still reach the
102
+ prompt; they are repository content, treated as untrusted.
103
+ - `sessions/`: Grok's session store, so `-r` works.
104
+ - `auth.json`: a symbolic link to your real `~/.grok/auth.json`. `grok login`, `grok logout`
105
+ and token refreshes write through it; ELU Code holds no copy. If Grok ever replaces the
106
+ link with a file, the newer token wins at the next run.
107
+
108
+ There is no `~/.claude` under that home, so the `isolation` step shows what actually
109
+ reaches a run on your machine. If it fails, Grok stays off here and the session says why.
110
+
111
+ ## Telemetry and uploads
112
+
113
+ On consumer plans Grok's product analytics, session trace upload (which includes file
114
+ contents) and per-turn workspace snapshots are on by default. ELU Code turns off every
115
+ documented switch, in the environment and in `config.toml`: `GROK_TELEMETRY_ENABLED=0`,
116
+ `GROK_TELEMETRY_TRACE_UPLOAD=0`, `GROK_TELEMETRY_MIXPANEL_ENABLED=0`,
117
+ `GROK_FEEDBACK_ENABLED=0`, `GROK_MEMORY=0`, `GROK_SUBAGENTS=0`, `GROK_CAMPAIGNS=0`,
118
+ `GROK_DISABLE_AUTOUPDATER=1`, `[telemetry] trace_upload=false`,
119
+ `[harness] disable_workspace_teleport=true` and the older `disable_codebase_upload=true`
120
+ (the key that stopped per-turn repository uploads on earlier releases), `[relay]
121
+ enabled=false`. With trace upload on, a headless run can spend up to 150 seconds draining
122
+ uploads at exit; off, it ends when the turn ends. Grok's server-side web search
123
+ (`[features] backend_tools`, `GROK_BACKEND_SEARCH`) runs inside the model call, where no
124
+ permission rule applies, so it is off in every mode; the model's own web tools follow the
125
+ mode. Account-level settings for coding data, retention and training live in Grok's own
126
+ `/privacy` screen; ELU Code cannot change them.
127
+
128
+ ## What is enforced and what is not
129
+
130
+ Enforced by the vendor and proven by the one-time check on your machine: no writes, no shell and
131
+ no network in Read only; the schema result; resume; where `relay` passed, a live question
132
+ before an outside write in Ask.
133
+
134
+ Not enforced, or not proven:
135
+
136
+ - Deny rules match command text. Obfuscated commands reach Auto's reviewer model and run
137
+ unprompted in Full.
138
+ - `--permission-mode` values other than `bypassPermissions` in headless mode are
139
+ documented for the enterprise build; the check's locked steps prove `dontAsk` here, and
140
+ Ask falls back to it only when `relay` failed.
141
+ - Whether the server-side search switch and the older codebase-upload key are honoured on
142
+ this release, and whether `auto` mode is available on your plan without
143
+ `[auto_mode] enabled`, is not settled by the docs.
144
+ - The exact ACP permission request shape and `session/load` come from the ACP standard and
145
+ Grok's docs; the `relay` step is the only proof.
146
+ - Whether Grok refreshes the token through the symbolic link or replaces it is not settled;
147
+ the rule above keeps the newer token either way.
148
+ - Workspace `AGENTS.md` and `CLAUDE.md` reach the prompt in every mode. A repository can
149
+ steer the model's instructions, not its permissions.
150
+ - A full shell in Full access can push, tag, merge, deploy and publish. ELU Code keeps the
151
+ prompt line, the deny rules and its own publish gate, and claims no more.
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { spawn } from 'node:child_process';
5
+ import { EluCodeError, atomic, noSymlinks, refuse } from './util.mjs';
6
+ import { killGroup, processIdentity } from './process.mjs';
7
+
8
+ // Newline-delimited JSON-RPC 2.0 over a child's stdio, as the ACP vendors speak it. No
9
+ // dependencies. The child's lines are appended to logFile, so a run's log reads like a
10
+ // vendor's stream; stderr goes to <logFile>.stderr. The same logFile, processFile, signal
11
+ // and hold plumbing as runProcess, and `exit` resolves to the shape runProcess returns.
12
+ const OUTPUT_LIMIT = 16 * 1024 * 1024, LINE_LIMIT = 4 * 1024 * 1024, REQUEST_TIMEOUT_MS = 60000;
13
+ const rpcError = (message, code) => Object.assign(new EluCodeError(message, { code }), { code });
14
+
15
+ export async function startAcp({ bin, args = [], cwd, env, logFile, processFile, signal, hold = null, timeoutSeconds = null }) {
16
+ signal?.throwIfAborted();
17
+ if (typeof bin !== 'string' || !bin) refuse('An ACP client needs a program to start.');
18
+ noSymlinks(logFile);
19
+ const fd = fs.openSync(logFile, 'wx', 0o600), stderrFile = `${logFile}.stderr`;
20
+ noSymlinks(stderrFile);
21
+ const stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
22
+ let child;
23
+ try { child = spawn(bin, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', stderrFd] }); }
24
+ finally { fs.closeSync(stderrFd); }
25
+ const pending = new Map(), handlers = new Map();
26
+ let next = 0, written = 0, timedOut = false, outputLimit = false, closed = false, elapsed = 0;
27
+ const stop = () => killGroup(child.pid, 'SIGKILL');
28
+ const send = value => { if (child.stdin.destroyed || closed) return false; child.stdin.write(`${JSON.stringify(value)}\n`); return true; };
29
+ const log = line => { written += line.length + 1; if (written > OUTPUT_LIMIT) { outputLimit = true; stop(); return; } fs.writeSync(fd, `${line}\n`); };
30
+ const failAll = reason => { for (const [id, entry] of pending) { pending.delete(id); clearTimeout(entry.timer); entry.reject(rpcError(reason, -32001)); } };
31
+ if (child.pid) {
32
+ const record = { pid: child.pid, identity: processIdentity(child.pid), bin: path.basename(bin) };
33
+ if (processFile) atomic(processFile, record);
34
+ }
35
+ // A held run (an approval waiting for the owner) does not spend its time limit.
36
+ const timer = timeoutSeconds ? setInterval(() => { if (hold?.()) return; elapsed += 1; if (elapsed >= timeoutSeconds) { timedOut = true; stop(); } }, 1000) : null;
37
+ signal?.addEventListener('abort', stop, { once: true });
38
+ if (signal?.aborted) stop();
39
+ for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(name, stop);
40
+ child.stdin.on('error', () => {});
41
+ const exit = new Promise(resolve => {
42
+ child.once('error', error => resolve({ code: null, error: error.message }));
43
+ child.once('close', (code, signalName) => resolve({ code, signal: signalName }));
44
+ }).then(result => {
45
+ closed = true; if (timer) clearInterval(timer); stop();
46
+ signal?.removeEventListener('abort', stop);
47
+ for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(name, stop);
48
+ if (processFile) atomic(processFile, { pid: null, identity: null });
49
+ fs.closeSync(fd);
50
+ failAll(`${path.basename(bin)} exited.`);
51
+ return { ...result, timedOut, outputLimit, stderrFile, log: logFile };
52
+ });
53
+ // Each server line: a reply to one of our requests, a request we must answer, or a notification.
54
+ async function receive(row) {
55
+ if (row.id !== undefined && row.method === undefined) {
56
+ const entry = pending.get(row.id); if (!entry) return;
57
+ pending.delete(row.id); clearTimeout(entry.timer);
58
+ if (row.error) entry.reject(rpcError(String(row.error.message ?? 'The vendor returned an error.').slice(0, 1000), Number.isInteger(row.error.code) ? row.error.code : -32000));
59
+ else entry.resolve(row.result);
60
+ return;
61
+ }
62
+ if (typeof row.method !== 'string') return;
63
+ const handler = handlers.get(row.method);
64
+ if (row.id === undefined) { if (handler) { try { await handler(row.params); } catch { /* A notification handler's failure is the handler's own. */ } } return; }
65
+ if (!handler) { send({ jsonrpc: '2.0', id: row.id, error: { code: -32601, message: 'Method not found' } }); return; }
66
+ try { send({ jsonrpc: '2.0', id: row.id, result: await handler(row.params) ?? null }); }
67
+ catch (error) { send({ jsonrpc: '2.0', id: row.id, error: { code: Number.isInteger(error.code) ? error.code : -32000, message: String(error.message ?? 'The handler failed.').slice(0, 1000) } }); }
68
+ }
69
+ const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
70
+ let chain = Promise.resolve();
71
+ lines.on('line', line => {
72
+ if (line.length > LINE_LIMIT) { outputLimit = true; stop(); return; }
73
+ log(line);
74
+ let row; try { row = JSON.parse(line); } catch { return; }
75
+ if (!row || typeof row !== 'object') return;
76
+ chain = chain.then(() => receive(row));
77
+ });
78
+ return {
79
+ pid: child.pid, exit,
80
+ request(method, params = {}, { timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
81
+ if (typeof method !== 'string' || !method) refuse('A request names its method.');
82
+ if (closed) return Promise.reject(rpcError(`${path.basename(bin)} exited.`, -32001));
83
+ const id = ++next;
84
+ return new Promise((resolve, reject) => {
85
+ const entry = { resolve, reject, timer: setTimeout(() => { if (pending.delete(id)) reject(rpcError(`No answer to ${method} within ${Math.round(timeoutMs / 1000)} seconds.`, -32002)); }, timeoutMs) };
86
+ pending.set(id, entry);
87
+ if (!send({ jsonrpc: '2.0', id, method, params })) { pending.delete(id); clearTimeout(entry.timer); reject(rpcError(`${path.basename(bin)} exited.`, -32001)); }
88
+ });
89
+ },
90
+ notify(method, params = {}) { if (typeof method !== 'string' || !method) refuse('A notification names its method.'); return send({ jsonrpc: '2.0', method, params }); },
91
+ on(method, handler) { if (typeof method !== 'string' || typeof handler !== 'function') refuse('on(method, handler) needs a method name and a function.'); handlers.set(method, handler); },
92
+ close() {
93
+ if (closed) return exit;
94
+ try { child.stdin.end(); } catch { /* Already closed. */ }
95
+ killGroup(child.pid, 'SIGTERM');
96
+ const force = setTimeout(stop, 250);
97
+ return exit.then(result => { clearTimeout(force); return result; });
98
+ },
99
+ };
100
+ }