@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/lib/modes.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { atomic, noSymlinks, onDisk, onDiskUp, personalDirectory, readJson, refuse, within } from './util.mjs';
5
+ import { HOME_SECRETS } from './vendors.mjs';
6
+
7
+ // Per-session permission modes. The owner picks one of MODES for a session; `locked` is
8
+ // the profile every other run gets: read-only, no shell, no network, edits only in a
9
+ // task worktree. Modes live in task.json and the request row, never in repository files.
10
+ export const MODES = Object.freeze(['ask', 'auto', 'full']);
11
+ export const PROFILES = Object.freeze(['locked', 'ask', 'auto', 'full']);
12
+ export const PLACES = Object.freeze(['worktree', 'branch', 'here']);
13
+ export const LABELS = Object.freeze({ locked: 'Read only', ask: 'Ask for approval', auto: 'Approve for me', full: 'Full access' });
14
+ export const BADGES = Object.freeze({ locked: 'Read only', ask: 'Ask', auto: 'Auto', full: 'Full' });
15
+ export const DEFAULT_MODE = 'ask';
16
+ export const APPROVAL_WAIT_SECONDS = 1200;
17
+ // Asks and reviews from a teammate run hardened: read-only, Claude only, no state reads.
18
+ export const PEER_RUN_KINDS = Object.freeze(['ask', 'review']);
19
+ export const isPeerRun = origin => PEER_RUN_KINDS.includes(origin?.kind);
20
+ const BRANCH = /^[A-Za-z0-9._/-]{1,200}$/, BAD_BRANCH = /(^|\/)\.|\.\.|\/\/|\.lock$|^-|\/$|^\/|@\{/;
21
+ const FOLDER_LIMIT = 8;
22
+
23
+ export function checkMode(value, { allowFull = true } = {}) {
24
+ if (!MODES.includes(value)) refuse('Choose ask, auto, or full.');
25
+ if (value === 'full' && !allowFull) refuse('Schedules run with Approve for me at most.');
26
+ return value;
27
+ }
28
+ // A place is 'worktree', 'here', 'branch:<name>', or { kind, branch }. Returns { kind, branch? }.
29
+ export function checkPlace(place, { mode } = {}) {
30
+ let kind, branch;
31
+ if (place == null) kind = 'worktree';
32
+ else if (typeof place === 'string') [kind, branch] = place.startsWith('branch:') ? ['branch', place.slice(7)] : [place];
33
+ else if (typeof place === 'object' && !Array.isArray(place)) ({ kind, branch } = place);
34
+ if (!PLACES.includes(kind)) refuse('Choose worktree, branch, or here.');
35
+ if (kind === 'here' && mode !== 'full') refuse('Here needs Full access.');
36
+ if (kind === 'branch') { if (typeof branch !== 'string' || !BRANCH.test(branch) || BAD_BRANCH.test(branch)) refuse('Choose a plain branch name.'); return { kind, branch }; }
37
+ if (branch !== undefined) refuse('Only a branch place names a branch.');
38
+ return { kind };
39
+ }
40
+ // Extra folders the lead may read this session: existing directories in the owner's home,
41
+ // never a secret or ELU Code state folder, at most eight, each listed once.
42
+ export function checkFolders(list, { home = os.homedir() } = {}) {
43
+ if (list == null) return [];
44
+ if (!Array.isArray(list) || list.some(item => typeof item !== 'string')) refuse('Folders must be a list of paths.');
45
+ const root = fs.realpathSync(home), denied = [...HOME_SECRETS.map(name => path.join(root, name)), path.resolve(personalDirectory())];
46
+ // The closed folders by what they are on disk, and the folders that hold one: a closed
47
+ // folder whose own name is a link is closed where it leads, and so is what lies above that.
48
+ const closed = new Set(denied.map(onDisk).filter(Boolean));
49
+ const holders = new Set(denied.flatMap(dir => { try { return onDiskUp(path.dirname(fs.realpathSync(dir))); } catch { return []; } }).filter(Boolean));
50
+ const out = [];
51
+ for (const item of list) {
52
+ if (!path.isAbsolute(item) || /[\x00-\x1f]/.test(item)) refuse('Folders must be absolute paths in your home.');
53
+ let real; try { if (!fs.statSync(item).isDirectory()) throw new Error('not a directory'); real = fs.realpathSync(item); } catch { refuse(`Folder not found: ${item}`); }
54
+ if (!within(root, real) || real === root) refuse('Folders must be inside your home, not your home itself.');
55
+ const here = onDiskUp(real);
56
+ if (denied.some(dir => within(dir, real) || within(real, dir)) || here.some(id => !id || closed.has(id)) || holders.has(here[0])) refuse('That folder holds secrets or ELU Code state.');
57
+ if (!out.includes(real)) out.push(real);
58
+ }
59
+ if (out.length > FOLDER_LIMIT) refuse(`Name at most ${FOLDER_LIMIT} folders.`);
60
+ return out;
61
+ }
62
+ // The named folders a turn in Ask for approval or Approve for me is denied writing to. A
63
+ // folder may hold the workspace (the folder of all your projects holds each project and the
64
+ // worktrees beside it) or lie inside it; such a folder is left to the workspace's own rules,
65
+ // because in the vendors a deny wins over an allow and would refuse every edit there. So is
66
+ // a folder that holds one the run itself writes to (keep). Compared as written and on disk.
67
+ export function readOnlyFolders(folders, { cwd, keep = [] }) {
68
+ const spelled = p => { const resolved = path.resolve(p); let real = resolved; try { real = fs.realpathSync(resolved); } catch { /* Missing: as written. */ } return [...new Set([resolved, real])]; };
69
+ const work = spelled(cwd), kept = keep.flatMap(spelled);
70
+ const above = new Set([...work, ...kept].flatMap(onDiskUp).filter(Boolean)), workIds = new Set(work.map(onDisk).filter(Boolean));
71
+ const left = dir => spelled(dir).some(p => work.some(w => within(p, w) || within(w, p)) || kept.some(k => within(p, k)) || above.has(onDisk(p)) || onDiskUp(p).some(id => workIds.has(id)));
72
+ return folders.filter(dir => !left(dir));
73
+ }
74
+ // The lower of two profiles, so a schedule or an automatic request never exceeds its cap.
75
+ export function capMode(mode, cap) {
76
+ const at = value => { const index = PROFILES.indexOf(value); if (index < 0) refuse('Choose ask, auto, or full.'); return index; };
77
+ return PROFILES[Math.min(at(mode), at(cap))];
78
+ }
79
+ const defaultFile = () => path.join(personalDirectory(), 'permissions.json');
80
+ // The personal default for new sessions. Anything unreadable means ask.
81
+ export function defaultMode() {
82
+ try { const file = defaultFile(); noSymlinks(file); if (!fs.existsSync(file)) return DEFAULT_MODE; const value = readJson(file).default; return MODES.includes(value) ? value : DEFAULT_MODE; }
83
+ catch { return DEFAULT_MODE; }
84
+ }
85
+ export function saveDefaultMode(mode) {
86
+ checkMode(mode); atomic(defaultFile(), { version: 1, default: mode }); return mode;
87
+ }
88
+ // Which profile a turn runs with, checked in order: only the owner's lead
89
+ // gets a mode; teammate questions, reviews and read-only turns stay locked; a task without
90
+ // permissions was saved before modes existed and stays locked.
91
+ export function runProfile({ roleName, task, origin = null, readOnly = false }) {
92
+ if (roleName !== 'lead') return 'locked';
93
+ if (isPeerRun(origin) || readOnly) return 'locked';
94
+ return MODES.includes(task?.permissions) ? task.permissions : 'locked';
95
+ }
@@ -0,0 +1,110 @@
1
+ // The never list in words: for the chat's Blocked line, for the instructions every model
2
+ // gets and for what a stand-in command says when it refuses (never-shims.mjs). The rules
3
+ // that refuse are each vendor's own, in its adapter; nothing here allows or refuses anything.
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { NEVER_RULES, MERGE_RULE, SENSITIVE } from './adapters/claude.mjs';
7
+ import { HOME_SECRETS } from './vendors.mjs';
8
+ import { personalDirectory } from './util.mjs';
9
+ import { approvalSummary } from './approvals.mjs';
10
+
11
+ const named = rule => /^Bash\((.+):\*\)$/.exec(rule)[1];
12
+ // The commands no mode allows, as the rules name them.
13
+ export const NEVER_COMMANDS = Object.freeze(NEVER_RULES.map(named));
14
+ // merge: a bot the owner lets merge pull requests; gh pr merge is then not on its list.
15
+ export const neverCommands = ({ merge = false } = {}) => merge === true ? NEVER_COMMANDS.filter(command => command !== named(MERGE_RULE)) : [...NEVER_COMMANDS];
16
+ // The refusal as the Blocked line and a stand-in command word it.
17
+ export const neverSentence = command => `${command} is never allowed, in any mode`;
18
+ // The never-list command a stand-in refused, read from what a command printed, or null. A
19
+ // stand-in prints the sentence as a line of its own.
20
+ export function refusedIn(output) {
21
+ for (const line of String(output ?? '').split('\n')) { const said = line.trim(), found = NEVER_COMMANDS.find(command => said === `${neverSentence(command)}.`); if (found) return found; }
22
+ return null;
23
+ }
24
+ // One line for the model, once a turn: the commands by name, that a longer line holding
25
+ // one counts too, and the read-only way to list tags, which models reach for git tag to do.
26
+ export const neverLine = options => `Never run these, alone or inside a longer command line, in any mode: ${neverCommands(options).join(', ')}. To list tags use git for-each-ref --sort=-creatordate refs/tags or git describe --tags, never git tag.`;
27
+
28
+ // The commands of a shell line as a vendor's rules read it: each side of && || ; | |& & and
29
+ // a new line, and what runs inside $( ), backticks and ( ). Quoted text is one piece.
30
+ export function shellParts(line) {
31
+ const text = String(line ?? ''), out = [], stack = [{ close: null, quote: null, text: '' }];
32
+ const cut = () => { const frame = stack.at(-1), part = frame.text.trim(); if (part) out.push(part); frame.text = ''; };
33
+ for (let i = 0; i < text.length; i++) {
34
+ const frame = stack.at(-1), ch = text[i], two = text.slice(i, i + 2);
35
+ if (frame.quote === "'") { if (ch === "'") frame.quote = null; frame.text += ch; continue; }
36
+ if (ch === '\\') { frame.text += two; i++; continue; }
37
+ if (two === '$(') { stack.push({ close: ')', quote: null, text: '' }); i++; continue; }
38
+ if (ch === '`') { if (frame.close === '`') { cut(); stack.pop(); } else stack.push({ close: '`', quote: null, text: '' }); continue; }
39
+ if (frame.quote === '"') { if (ch === '"') frame.quote = null; frame.text += ch; continue; }
40
+ if (ch === '"' || ch === "'") { frame.quote = ch; frame.text += ch; continue; }
41
+ if (ch === '(') { cut(); stack.push({ close: ')', quote: null, text: '' }); continue; }
42
+ if (ch === ')') { cut(); if (stack.length > 1 && frame.close === ')') stack.pop(); continue; }
43
+ if (['&&', '||', '|&', ';;'].includes(two)) { cut(); i++; continue; }
44
+ // 2>&1 and &> point output somewhere; they do not end a command.
45
+ if (ch === '&' && (text[i - 1] === '>' || text[i + 1] === '>')) { frame.text += ch; continue; }
46
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') { cut(); continue; }
47
+ frame.text += ch;
48
+ }
49
+ while (stack.length) { cut(); stack.pop(); }
50
+ return out;
51
+ }
52
+ // What is dropped before a command is compared: the shell words that open a body, a leading
53
+ // VAR=value, and the wrappers timeout, time, nice, nohup, stdbuf, command, builtin, noglob
54
+ // and a bare xargs.
55
+ function bare(part) {
56
+ let text = part.replace(/\s+/g, ' ').trim();
57
+ for (let was = null; was !== text;) {
58
+ was = text;
59
+ text = text.replace(/^(?:if|then|else|elif|do|while|until|!|\{)\s+/, '')
60
+ .replace(/^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s"']*)\s+/, '')
61
+ .replace(/^(?:time|nohup|builtin|noglob)\s+/, '')
62
+ .replace(/^command\s+(?!-[vV]\b)/, '')
63
+ .replace(/^nice(?:\s+(?:-n\s*-?\d+|--adjustment=-?\d+|-\d+))?\s+/, '')
64
+ .replace(/^stdbuf(?:\s+-\S+)*\s+/, '')
65
+ .replace(/^timeout(?:\s+(?:-[sk]\s+\S+|--(?:signal|kill-after)(?:=\S+|\s+\S+)|-\S+))*\s+\S+\s+/, '')
66
+ .replace(/^xargs\s+(?!-)/, '');
67
+ }
68
+ return text;
69
+ }
70
+ // The never-list command a shell line holds, alone or as one of its parts, or null. A line
71
+ // with a here-document is not read: its body is text, not commands, and this cannot tell
72
+ // the two apart.
73
+ export function neverPart(line, options) {
74
+ const text = String(line ?? '');
75
+ if (/<<[^<]/.test(text)) return null;
76
+ const list = neverCommands(options);
77
+ for (const part of shellParts(text)) { const command = bare(part), found = list.find(name => command === name || command.startsWith(`${name} `)); if (found) return found; }
78
+ return null;
79
+ }
80
+
81
+ const READS = ['Read', 'Grep', 'Glob'], EDITS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
82
+ // The vendors whose never list is a rule or a hook on the command's text. Where stand-in
83
+ // commands hold the list, the adapter says which command one refused (denial.never).
84
+ const RULED = ['claude', 'grok', 'agy'];
85
+ const within = (file, dir) => file === dir || file.startsWith(`${dir}${path.sep}`);
86
+ const glob = name => new RegExp(`^${name.replace(/[.+^$(){}|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
87
+ // Why no mode would have allowed a refused action, or null when a wider mode could.
88
+ // never: a never-list command; command is its name, line the whole line, alone whether
89
+ // the line runs nothing else. denial.never: the command a stand-in said it refused
90
+ // secret, own: a file that may hold secrets, or ELU Code's own, closed in every mode
91
+ // owner, timeout: Full access asked the owner, who said no or did not answer
92
+ // rule: refused in Full access by something this cannot name
93
+ // asked: the approvals of that run, as the task keeps them.
94
+ export function whyBlocked(denial, { mode = null, vendor = null, asked = [] } = {}) {
95
+ const tool = String(denial?.tool ?? ''), input = denial?.input && typeof denial.input === 'object' ? denial.input : {}, field = name => typeof input[name] === 'string' ? input[name] : '';
96
+ const summary = typeof denial?.summary === 'string' ? denial.summary : '';
97
+ const line = tool === 'Bash' ? field('command') : /^Run:? /.test(summary) ? summary.replace(/^Run:? /, '') : '';
98
+ if (line && RULED.includes(vendor)) { const command = neverPart(line); if (command) return { kind: 'never', command, line, alone: shellParts(line).length === 1 }; }
99
+ // A stand-in command refused it, wherever in the line or in a script it ran. The line is shown without the shell a vendor wraps it in.
100
+ if (NEVER_COMMANDS.includes(denial?.never)) { const inner = /^\S*sh -l?c (['"])(.*)\1$/s.exec(line)?.[2] ?? line; return { kind: 'never', command: denial.never, line: inner || denial.never, alone: neverPart(inner) === denial.never && shellParts(inner).length === 1 }; }
101
+ const file = field('file_path') || field('notebook_path') || field('path');
102
+ if (file && vendor === 'claude' && [...READS, ...EDITS].includes(tool)) {
103
+ const full = path.resolve(file);
104
+ if (within(full, path.resolve(personalDirectory()))) return { kind: 'own' };
105
+ if (READS.includes(tool) && (SENSITIVE.some(name => glob(name).test(path.basename(full))) || HOME_SECRETS.some(name => within(full, path.join(os.homedir(), name))))) return { kind: 'secret' };
106
+ }
107
+ if (mode !== 'full') return null;
108
+ const answer = summary || !tool ? null : asked.findLast(row => row.decision !== 'allow' && row.summary === approvalSummary(tool, input));
109
+ return { kind: answer ? answer.decision === 'timeout' ? 'timeout' : 'owner' : 'rule' };
110
+ }
@@ -0,0 +1,311 @@
1
+ // Stand-in commands for the never list. One small script per command word (git, gh, npm and
2
+ // the rest) in a private folder that goes first on a session's PATH: it refuses the calls no
3
+ // mode allows, by name, and hands every other call, arguments untouched, to the real command.
4
+ // It reaches what a rule on the model's own command line cannot: a script the session runs,
5
+ // sh -c, a wrapper. It is not a sandbox. A full path to the real command, a changed PATH, a
6
+ // Git alias or another program that does the same work gets past it, and a session in Full
7
+ // access can rewrite the folder; it is written again whenever a session starts.
8
+ //
9
+ // Two things are not publishing and pass: a git push whose destination is a folder on this
10
+ // machine, and a git merge or git tag in a scratch repository under the system temporary
11
+ // directory. A project's own tests do both.
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { neverCommands, neverSentence } from './never-list.mjs';
17
+ import { EluCodeError, quote, refuse } from './util.mjs';
18
+
19
+ // The vendors whose sessions get the folder first on PATH: all of them. Where a vendor has
20
+ // rules on the command's text as well (Claude Code, Antigravity, Grok Build), those stay;
21
+ // the stand-ins reach what a script runs, which that text does not show.
22
+ export const SHIM_VENDORS = Object.freeze(['codex', 'claude', 'agy', 'grok']);
23
+ // What a stand-in exits with when it refuses.
24
+ export const REFUSED = 126;
25
+ // Outside the project and outside ELU Code's own state, which a session may not read: a
26
+ // session has to be able to read and run these. Nothing in a sandboxed mode may write here.
27
+ export function shimsBase() {
28
+ const data = process.env.XDG_DATA_HOME;
29
+ return path.join(data && path.isAbsolute(data) ? data : path.join(os.homedir(), '.local', 'share'), 'elu-code', 'never-list');
30
+ }
31
+ const folderOf = merge => path.join(shimsBase(), merge === true ? 'merge-allowed' : 'all');
32
+ // The system temporary directory, links resolved: the one ELU Code itself was given, and /tmp.
33
+ // Read when the stand-ins are written, so a session cannot name another folder by changing TMPDIR.
34
+ export function scratchRoots() {
35
+ const out = [];
36
+ for (const dir of [os.tmpdir(), '/tmp']) { try { const real = fs.realpathSync(dir); if (real !== path.parse(real).root && !out.includes(real)) out.push(real); } catch { /* Not on this system. */ } }
37
+ return out;
38
+ }
39
+
40
+ const WORD = /^[a-z][a-z0-9-]*$/;
41
+ // The never list by command word: { git: [['push'], ['tag'], ['merge']], gh: [['pr', 'merge'], ['release']], … }.
42
+ function byWord(options) {
43
+ const out = {};
44
+ for (const command of neverCommands(options)) {
45
+ const [name, ...rest] = command.split(' ');
46
+ if (![name, ...rest].every(word => WORD.test(word)) || rest.length < 1 || rest.length > 2) throw new Error(`No stand-in can read this never-list command: ${command}`);
47
+ (out[name] ??= []).push(rest);
48
+ }
49
+ return out;
50
+ }
51
+ // What every stand-in starts with: how it refuses, and how it finds and runs the real command,
52
+ // which is the first one on PATH outside the stand-in folders and never this file.
53
+ const head = (shell, name, base) => `#!${shell}
54
+ # Written by ELU Code and written again whenever a session starts. It refuses the commands no
55
+ # mode allows and hands every other call, arguments untouched, to the real ${name}.
56
+ set -f
57
+ name=${name}
58
+ base=${quote(base)}
59
+ refuse() { printf '${neverSentence('%s')}.\\n' "$1" >&2; exit ${REFUSED}; }
60
+ find_real() {
61
+ self=\${0%/*}
62
+ old=$IFS; IFS=:
63
+ for d in $PATH; do
64
+ IFS=$old
65
+ [ -n "$d" ] || d=.
66
+ case $d/ in "$base"/*) continue;; esac
67
+ [ "$d" = "$self" ] && continue
68
+ [ -f "$d/$name" ] && [ -x "$d/$name" ] || continue
69
+ [ "$d/$name" -ef "$0" ] 2>/dev/null && continue
70
+ real=$d/$name; return 0
71
+ done
72
+ IFS=$old
73
+ return 1
74
+ }
75
+ run() { find_real || { printf '%s: command not found\\n' "$name" >&2; exit 127; }; exec "$real" "$@"; }
76
+ `;
77
+ const tail = 'run "$@"\n';
78
+ // git, read as git reads it: its own options first (these take the next word as their value),
79
+ // then the command. Listing tags changes nothing and build scripts do it, so git tag alone and
80
+ // git tag -l or --list pass.
81
+ const GIT_VALUES = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--shallow-file', '--attr-source'];
82
+ // What lets a never-list git call through when it stays on this machine. A word with no rule
83
+ // here is refused outright.
84
+ const GIT_LOCAL = { push: 'push_local', merge: 'scratch', tag: 'scratch' };
85
+ // The options of git push that change nothing about where it goes. Any other option is one
86
+ // this script cannot read, and the push is refused.
87
+ const PUSH_FLAGS = ['all', 'branches', 'mirror', 'tags', 'follow-tags', 'no-follow-tags', 'atomic', 'no-atomic', 'dry-run', 'porcelain', 'delete', 'prune', 'no-prune', 'force', 'no-force', 'force-with-lease', 'no-force-with-lease',
88
+ 'force-if-includes', 'no-force-if-includes', 'set-upstream', 'thin', 'no-thin', 'quiet', 'verbose', 'progress', 'no-progress', 'verify', 'no-verify', 'signed', 'no-signed', 'ipv4', 'ipv6'];
89
+ function gitBody(paths, roots) {
90
+ if (paths.some(words => words.length !== 1)) throw new Error('A git entry of the never list names more than its command.');
91
+ const words = paths.map(([word]) => word), local = words.filter(word => GIT_LOCAL[word]), outright = words.filter(word => !GIT_LOCAL[word]);
92
+ const later = local.filter(word => word !== 'tag');
93
+ return `seen=0; sub=; more=0; first=; skip=0; nopt=0
94
+ for a do
95
+ if [ $seen = 1 ]; then more=1; first=$a; break; fi
96
+ if [ $skip = 1 ]; then skip=0; nopt=$((nopt+1)); continue; fi
97
+ case $a in
98
+ ${GIT_VALUES.join('|')}) skip=1; nopt=$((nopt+1));;
99
+ -*) nopt=$((nopt+1));;
100
+ *) seen=1; sub=$a;;
101
+ esac
102
+ done
103
+ case $sub in
104
+ ${later.length ? ` ${later.join('|')}) ;;\n` : ''}${local.includes('tag') ? ' tag) case $more$first in 0|1-l|1--list) run "$@";; esac;;\n' : ''}${outright.length ? ` ${outright.join('|')}) refuse "$name $sub";;\n` : ''} *) run "$@";;
105
+ esac
106
+ # Only a call the never list names reads on from here. Pushing to a folder on this machine and
107
+ # working in a scratch repository under the temporary directory are not publishing, so those
108
+ # pass. git itself is asked where the push would go and which repository the call acts on;
109
+ # whatever it cannot say is refused.
110
+ find_real || refuse "$name $sub"
111
+ nl='
112
+ '
113
+ # The real git with this call's own options (-C, -c, --git-dir and the rest), then q1 to q5.
114
+ gitq() {
115
+ qt=$#; qi=0
116
+ while [ $qi -lt $qt ]; do
117
+ qa=$1; shift; qi=$((qi+1))
118
+ [ $qi -gt $nopt ] || set -- "$@" "$qa"
119
+ done
120
+ "$real" "$@" \${q1:+"$q1"} \${q2:+"$q2"} \${q3:+"$q3"} \${q4:+"$q4"} \${q5:+"$q5"} </dev/null 2>/dev/null
121
+ }
122
+ # Whether git reads this as a place on this machine: a file URL, or a path with no host before it.
123
+ islocal() {
124
+ case $1 in
125
+ '') return 1;;
126
+ file://*) return 0;;
127
+ *://*) return 1;;
128
+ *:*) case \${1%%:*} in */*) return 0;; esac; return 1;;
129
+ esac
130
+ return 0
131
+ }
132
+ # Whether every place git would push to for $1 (a remote, a path or a URL) is on this machine.
133
+ dest_local() {
134
+ dl=$1; shift
135
+ [ -n "$dl" ] || return 1
136
+ # A remote of this repository: git gives its push URLs, with pushurl and every
137
+ # url.<base>.insteadOf and pushInsteadOf rule applied.
138
+ q1=remote q2=get-url q3=--push q4=--all q5=$dl
139
+ if urls=$(gitq "$@") && [ -n "$urls" ]; then
140
+ oldifs=$IFS; IFS=$nl
141
+ for u in $urls; do IFS=$oldifs; islocal "$u" || return 1; done
142
+ IFS=$oldifs
143
+ return 0
144
+ fi
145
+ # Not one. A bare name may be a remote from the user's own Git settings, so only what is
146
+ # written as a path or a file URL goes on, and only when nothing is set under that very name.
147
+ case $dl in /*|./*|../*|file://*) ;; *) return 1;; esac
148
+ q1=config q2=--get-all q3=remote.$dl.pushurl q4= q5=
149
+ gitq "$@" >/dev/null; [ $? = 1 ] || return 1
150
+ q3=remote.$dl.url
151
+ gitq "$@" >/dev/null; [ $? = 1 ] || return 1
152
+ # A pushInsteadOf rule that matches sends the push somewhere else.
153
+ q1=config q2=--get-regexp q3='^url\\..*\\.pushinsteadof$' q4= q5=
154
+ rules=$(gitq "$@")
155
+ oldifs=$IFS; IFS=$nl
156
+ for rule in $rules; do
157
+ IFS=$oldifs
158
+ while :; do
159
+ case $rule in *' '*) rule=\${rule#* };; *) break;; esac
160
+ case $dl in "$rule"*) return 1;; esac
161
+ done
162
+ done
163
+ IFS=$oldifs
164
+ q1=ls-remote q2=--get-url q3=$dl q4= q5=
165
+ u=$(gitq "$@") && islocal "$u"
166
+ }
167
+ # The remote a push with none named goes to, as git picks it.
168
+ default_remote() {
169
+ dr=; br=
170
+ q1=symbolic-ref q2=-q q3=--short q4=HEAD q5=
171
+ br=$(gitq "$@") || br=
172
+ if [ -n "$br" ]; then q1=config q2=--get q3=branch.$br.pushremote q4= q5=; dr=$(gitq "$@") || dr=; fi
173
+ if [ -z "$dr" ]; then q1=config q2=--get q3=remote.pushdefault q4= q5=; dr=$(gitq "$@") || dr=; fi
174
+ if [ -z "$dr" ] && [ -n "$br" ]; then q1=config q2=--get q3=branch.$br.remote q4= q5=; dr=$(gitq "$@") || dr=; fi
175
+ [ -z "$dr" ] || return 0
176
+ # None is set for the branch: the only remote when there is one, else origin.
177
+ q1=remote q2= q3= q4= q5=
178
+ dr=$(gitq "$@") || return 1
179
+ case $dr in '') return 1;; *"$nl"*) dr=origin;; esac
180
+ return 0
181
+ }
182
+ push_local() {
183
+ pn=$((nopt+1)); pi=0; repo=; has=0; pend=0; pskip=0
184
+ for pa do
185
+ pi=$((pi+1)); [ $pi -gt $pn ] || continue
186
+ if [ $pskip = 1 ]; then pskip=0; continue; fi
187
+ if [ $pend = 0 ]; then
188
+ case $pa in
189
+ --) pend=1; continue;;
190
+ -o|--push-option) pskip=1; continue;;
191
+ -o*|--push-option=*|--force-with-lease=*|--signed=*) continue;;
192
+ --no-recurse-submodules|--recurse-submodules=no|--recurse-submodules=check) continue;;
193
+ ${PUSH_FLAGS.map(flag => `--${flag}`).join('|')}) continue;;
194
+ --*) return 1;;
195
+ -*[!fnqvud46]*) return 1;;
196
+ -?*) continue;;
197
+ esac
198
+ fi
199
+ [ $has = 1 ] || { has=1; repo=$pa; }
200
+ done
201
+ # Submodules pushed on demand go to their own remotes.
202
+ q1=config q2=--get q3=push.recursesubmodules q4= q5=
203
+ sm=$(gitq "$@") || sm=
204
+ case $sm in ''|no|check|false|off|0) ;; *) return 1;; esac
205
+ if [ -z "$sm" ]; then q1=config q2=--type=bool q3=--get q4=submodule.recurse q5=; [ "$(gitq "$@")" != true ] || return 1; fi
206
+ if [ $has = 0 ]; then default_remote "$@" || return 1; repo=$dr; fi
207
+ dest_local "$repo" "$@"
208
+ }
209
+ # Whether a folder lies, links resolved, inside the system temporary directory.
210
+ intemp() {
211
+ ${roots.length ? ` it=$(cd -P -- "$1" 2>/dev/null && pwd -P) || return 1
212
+ case $it in ${roots.map(root => `${quote(root)}/?*`).join('|')}) return 0;; esac
213
+ ` : ''} return 1
214
+ }
215
+ # Whether the repository this call acts on is a scratch one: its top level and the folder that
216
+ # holds its history both lie inside the temporary directory. A worktree there of a repository
217
+ # kept somewhere else is not one.
218
+ scratch() {
219
+ q1=rev-parse q2=--show-toplevel q3= q4= q5=
220
+ top=$(gitq "$@") && [ -n "$top" ] || { q2=--absolute-git-dir; top=$(gitq "$@") && [ -n "$top" ]; } || return 1
221
+ q2=--path-format=absolute q3=--git-common-dir
222
+ common=$(gitq "$@") && [ -n "$common" ] || return 1
223
+ intemp "$top" && intemp "$common"
224
+ }
225
+ case $sub in
226
+ ${local.map(word => ` ${word}) ${GIT_LOCAL[word]} "$@" || refuse "$name ${word}";;\n`).join('')}esac
227
+ exec "$real" "$@"
228
+ `;
229
+ }
230
+ // Every other tool, read on the safe side: an option written without = may or may not take
231
+ // the next word as its value, so both readings are tried and either one that makes a
232
+ // never-list command refuses. skips: words the tool reads past before its command.
233
+ const SKIPS = { gcloud: ['alpha', 'beta', 'preview'] };
234
+ function body(name, paths) {
235
+ const one = paths.filter(words => words.length === 1).map(([word]) => word), two = paths.filter(words => words.length === 2);
236
+ const firsts = [...new Set(two.map(([word]) => word))], skips = SKIPS[name] ?? [];
237
+ return `looking=1; w=; prev=0
238
+ for a do
239
+ case $a in
240
+ ${name === 'cargo' ? '+*|' : ''}-*=*) prev=0; continue;;
241
+ -*) prev=1; continue;;
242
+ esac
243
+ ${two.length ? ` if [ -n "$w" ]; then
244
+ case "$w $a" in ${two.map(words => `'${words.join(' ')}'`).join('|')}) refuse "$name $w $a";; esac
245
+ [ $prev = 1 ] || w=
246
+ fi
247
+ ` : ''} if [ $looking = 1 ]; then
248
+ case $a in
249
+ ${skips.length ? ` ${skips.join('|')}) prev=0; continue;;\n` : ''}${one.length ? ` ${one.join('|')}) refuse "$name $a";;\n` : ''}${firsts.length ? ` ${firsts.join('|')}) w=$a;;\n` : ''} esac
250
+ [ $prev = 1 ] || looking=0
251
+ fi
252
+ prev=0
253
+ if [ $looking = 0 ] && [ -z "$w" ]; then break; fi
254
+ done
255
+ ${tail}`;
256
+ }
257
+ // The scripts, by file name. merge: a bot the owner lets merge pull requests; its gh stand-in
258
+ // then does not refuse gh pr merge. roots: the folders that count as the temporary directory.
259
+ export function shimScripts({ merge = false, roots = scratchRoots() } = {}) {
260
+ // dash starts faster than the bash that /bin/sh is on some systems; a session runs git hundreds of times.
261
+ const shell = fs.existsSync('/bin/dash') ? '/bin/dash' : '/bin/sh', base = shimsBase();
262
+ return Object.fromEntries(Object.entries(byWord({ merge })).map(([name, paths]) => [name, `${head(shell, name, base)}${name === 'git' ? gitBody(paths, roots) : body(name, paths)}`]));
263
+ }
264
+ // Writes the folder (0700, files 0755) and returns it. A file that already says the same is
265
+ // left alone; anything else found there is removed, so nothing a session left in a folder
266
+ // that is first on PATH stays for the next one.
267
+ export function writeShims({ merge = false, roots = scratchRoots() } = {}) {
268
+ const base = shimsBase(), folder = folderOf(merge);
269
+ try {
270
+ fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
271
+ for (const dir of [base, folder]) { const stat = fs.lstatSync(dir); if (!stat.isDirectory()) throw new Error(`${dir} is not a folder`); if ((stat.mode & 0o777) !== 0o700) fs.chmodSync(dir, 0o700); }
272
+ const scripts = shimScripts({ merge, roots });
273
+ for (const [name, text] of Object.entries(scripts)) {
274
+ const file = path.join(folder, name);
275
+ let same = false;
276
+ try { const stat = fs.lstatSync(file); same = stat.isFile() && (stat.mode & 0o777) === 0o755 && fs.readFileSync(file, 'utf8') === text; } catch { /* Written below. */ }
277
+ if (same) continue;
278
+ const draft = path.join(folder, `.${name}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
279
+ fs.writeFileSync(draft, text, { mode: 0o755, flag: 'wx' }); fs.chmodSync(draft, 0o755); fs.renameSync(draft, file);
280
+ }
281
+ // A name that starts with a dot is another launch's file on its way in.
282
+ for (const name of fs.readdirSync(folder)) if (!Object.hasOwn(scripts, name) && !name.startsWith('.')) fs.rmSync(path.join(folder, name), { recursive: true, force: true });
283
+ } catch (error) {
284
+ if (error instanceof EluCodeError) throw error;
285
+ refuse(`ELU Code could not write its stand-in commands for the never list: ${error.message}`);
286
+ }
287
+ return folder;
288
+ }
289
+ // PATH for a session: the folder first, and no other stand-in folder after it. It returns the
290
+ // text and changes nothing else: ELU Code's own checks never run behind the stand-ins.
291
+ export function shimPath(current = '', { merge = false } = {}) {
292
+ const folder = writeShims({ merge }), base = shimsBase();
293
+ const rest = String(current ?? '').split(path.delimiter).filter(dir => dir && dir !== base && !dir.startsWith(`${base}${path.sep}`));
294
+ return [folder, ...rest].join(path.delimiter);
295
+ }
296
+ // Claude Code starts each command from a snapshot of the owner's shell setup. The snapshot
297
+ // builds PATH again, which leaves the folder behind the system's own. Claude Code runs the
298
+ // file named in CLAUDE_ENV_FILE ahead of each command, after the snapshot: this one line puts
299
+ // the folder first again. Returns the file, beside the folder it names.
300
+ export function shimEnvFile({ merge = false } = {}) {
301
+ const folder = writeShims({ merge }), file = `${folder}.path.sh`, text = `export PATH=${quote(folder)}:"$PATH"\n`;
302
+ try {
303
+ let same = false;
304
+ try { const stat = fs.lstatSync(file); same = stat.isFile() && fs.readFileSync(file, 'utf8') === text; } catch { /* Written below. */ }
305
+ if (!same) {
306
+ const draft = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
307
+ fs.writeFileSync(draft, text, { mode: 0o644, flag: 'wx' }); fs.renameSync(draft, file);
308
+ }
309
+ } catch (error) { refuse(`ELU Code could not write its stand-in commands for the never list: ${error.message}`); }
310
+ return file;
311
+ }
package/lib/paste.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+
3
+ // Bracketed paste. Asked for it, the terminal wraps pasted text in ESC[200~ … ESC[201~.
4
+ // blessed does not know the markers: it would type the paste one key at a time, drawing the
5
+ // screen after each one, and a newline would submit the message half way through. Here the
6
+ // paste is taken out of the input before blessed reads keys from it and handed over whole.
7
+
8
+ const START = '\x1b[200~', END = '\x1b[201~';
9
+ const CONTROL = /[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g;
10
+
11
+ // Where a trailing start of marker begins, or the text length when there is none. Outside a
12
+ // paste a lone escape is the Escape key, so only two or more characters are held back.
13
+ function partial(text, marker, least) {
14
+ for (let size = Math.min(marker.length - 1, text.length); size >= least; size--) if (text.endsWith(marker.slice(0, size))) return text.length - size;
15
+ return text.length;
16
+ }
17
+
18
+ // Pasted text as typed text: newlines of any kind, tabs, and no other control characters.
19
+ export const pastedText = text => text.replace(/\r\n?/g, '\n').replace(CONTROL, '');
20
+
21
+ // Splits a stream of input into keys for blessed and pastes for onPaste. Chunks may cut a
22
+ // marker or a character anywhere.
23
+ export function pasteSplitter(onKeys, onPaste) {
24
+ const decoder = new StringDecoder('utf8');
25
+ let held = '', pasting = false, pasted = '';
26
+ return chunk => {
27
+ let text = held + (typeof chunk === 'string' ? chunk : decoder.write(chunk)); held = '';
28
+ while (text) {
29
+ if (pasting) {
30
+ const end = text.indexOf(END);
31
+ if (end < 0) { const cut = partial(text, END, 1); pasted += text.slice(0, cut); held = text.slice(cut); return; }
32
+ pasted += text.slice(0, end); text = text.slice(end + END.length); pasting = false;
33
+ const value = pasted; pasted = ''; onPaste(value);
34
+ continue;
35
+ }
36
+ const start = text.indexOf(START);
37
+ if (start < 0) { const cut = partial(text, START, 2); if (cut) onKeys(text.slice(0, cut)); held = text.slice(cut); return; }
38
+ if (start) onKeys(text.slice(0, start));
39
+ text = text.slice(start + START.length); pasting = true;
40
+ }
41
+ };
42
+ }
43
+
44
+ // Turns bracketed paste on for the screen's terminal and routes each paste to onPaste, until
45
+ // the screen is destroyed.
46
+ export function attachPaste(screen, onPaste) {
47
+ const { program } = screen, input = program.input;
48
+ // blessed's key reader on the input, from keys.emitKeypressEvents.
49
+ const reader = input.listeners('data').find(fn => fn.name === 'onData');
50
+ if (!reader) return;
51
+ const split = pasteSplitter(text => reader(Buffer.from(text)), onPaste);
52
+ input.removeListener('data', reader); input.on('data', split);
53
+ program._write('\x1b[?2004h');
54
+ const destroy = screen.destroy.bind(screen);
55
+ screen.destroy = function() { program._write('\x1b[?2004l'); return destroy(); };
56
+ }