@elliotsystems/eai 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/bin/eai +53 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +44 -12
- package/lib/setup.js +231 -0
- package/lib/stage.js +116 -0
- package/package.json +8 -10
- package/plugin/opencode-plugin/README.md +64 -0
- package/plugin/opencode-plugin/package.json +32 -0
- package/plugin/opencode-plugin/src/classifier.ts +238 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +133 -0
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +211 -0
- package/plugin/opencode-plugin/src/mcp-keys.ts +30 -0
- package/plugin/opencode-plugin/src/policy.ts +122 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +96 -0
- package/plugin/opencode-plugin/src/scope.ts +78 -0
- package/plugin/opencode-plugin/src/tui/api.ts +117 -0
- package/plugin/opencode-plugin/src/tui/format.ts +73 -0
- package/plugin/opencode-plugin/src/tui/home.tsx +88 -0
- package/plugin/opencode-plugin/src/tui/host.d.ts +95 -0
- package/plugin/opencode-plugin/src/tui/index.tsx +99 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +243 -0
- package/plugin/opencode-plugin/src/tui/prompt.tsx +62 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +184 -0
- package/plugin/opencode-plugin/src/tui/wordmark.tsx +109 -0
- package/plugin/opencode-plugin/themes/elliot.json +105 -0
- package/plugin/opencode-plugin/tsconfig.json +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"eai": "./bin/eai"
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"bin",
|
|
10
10
|
"lib",
|
|
11
|
+
"config",
|
|
12
|
+
"plugin",
|
|
11
13
|
"README.md"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"test": "node --test test/*.test.js",
|
|
15
|
-
"vendor": "node scripts/vendor.js",
|
|
16
|
-
"assemble": "node scripts/assemble.js",
|
|
17
17
|
"publish:npm": "node scripts/publish.js",
|
|
18
|
-
"
|
|
18
|
+
"bundle": "node scripts/bundle.js",
|
|
19
|
+
"prepack": "node scripts/bundle.js",
|
|
20
|
+
"verify": "node scripts/verify-package.js"
|
|
19
21
|
},
|
|
20
22
|
"engines": {
|
|
21
23
|
"node": ">=18.0.0"
|
|
@@ -23,11 +25,7 @@
|
|
|
23
25
|
"publishConfig": {
|
|
24
26
|
"access": "public"
|
|
25
27
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"@elliotsystems/eai-darwin-x64": "0.1.2",
|
|
29
|
-
"@elliotsystems/eai-linux-x64": "0.1.2",
|
|
30
|
-
"@elliotsystems/eai-linux-arm64": "0.1.2",
|
|
31
|
-
"@elliotsystems/eai-windows-x64": "0.1.2"
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"opencode-ai": "1.18.30"
|
|
32
30
|
}
|
|
33
31
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @elliotsystems/opencode-plugin
|
|
2
|
+
|
|
3
|
+
Elliot AI's OpenCode integration. Phase C2 scope: repository-scope injection for
|
|
4
|
+
`elliot_knowledge_search`.
|
|
5
|
+
|
|
6
|
+
## What it does — and pointedly does not
|
|
7
|
+
|
|
8
|
+
The Elliot backend serves `knowledge_search` as an MCP tool on `/mcp/v1`; through the MCP
|
|
9
|
+
server name `elliot`, the agent sees **`elliot_knowledge_search`**. This plugin's single
|
|
10
|
+
job is to inject the session's repository (`owner/name`, derived from the worktree's git
|
|
11
|
+
remote) into that tool's arguments, so retrieval can be repo-focused **while the model
|
|
12
|
+
never names a repository**. The backend's `resolve_repo_scope` remains the only
|
|
13
|
+
authorizer — a wrong or forged hint can select nothing the tenant wasn't already granted.
|
|
14
|
+
|
|
15
|
+
No tokens (login/refresh/storage is OpenCode's native MCP OAuth — `opencode mcp auth
|
|
16
|
+
elliot`), no transport, no prompt injection.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
`opencode.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"$schema": "https://opencode.ai/config.json",
|
|
25
|
+
"mcp": {
|
|
26
|
+
"elliot": {
|
|
27
|
+
"type": "remote",
|
|
28
|
+
"url": "https://backend.elliotsystemsonline.com/mcp/v1",
|
|
29
|
+
"oauth": { "clientId": "elliot-opencode" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"plugin": [["@elliotsystems/opencode-plugin@0.1.0", { "serverNames": ["elliot"] }]]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then `opencode mcp auth elliot` once. Pin the plugin version — never `@latest`.
|
|
37
|
+
|
|
38
|
+
`serverNames` must match your `mcp` key(s); the plugin recognises the composed tool key
|
|
39
|
+
`<server>_knowledge_search` exactly (OpenCode composes MCP tool keys as
|
|
40
|
+
`sanitize(client) + "_" + sanitize(tool)`).
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install
|
|
46
|
+
npm test # vitest — includes the security cases (scope overwrite/delete)
|
|
47
|
+
npm run typecheck
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Load the working copy into OpenCode with a path spec:
|
|
51
|
+
`"plugin": [["file:///abs/path/to/opencode-plugin/src/index.ts", {}]]`.
|
|
52
|
+
|
|
53
|
+
Windows note: if `bun`/OpenCode installs hit `EPERM (NtSetInformationFile())` on native
|
|
54
|
+
packages, that's AV interference during cache renames — use
|
|
55
|
+
`bun install --backend=copyfile` and/or add an AV exclusion (see Phase B findings).
|
|
56
|
+
|
|
57
|
+
## Security invariants (tested)
|
|
58
|
+
|
|
59
|
+
- Session truth **overwrites** any pre-existing `repo_scope_hint` in tool args.
|
|
60
|
+
- With no derivable scope, a smuggled hint is **deleted**; search degrades to
|
|
61
|
+
tenant-wide (authorized, just unfocused) — never an error, never a forged scope.
|
|
62
|
+
- Only the exact composed key on configured servers is touched; every other tool's
|
|
63
|
+
args pass through byte-identical.
|
|
64
|
+
- Options are zod-`strict()`: unknown keys fail the plugin at load, loudly.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elliotsystems/opencode-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Elliot AI integration for OpenCode: tenant-aware knowledge tools, repository scope, and policy.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./tui": "./src/tui/index.tsx"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"themes",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@opentui/core": ">=0.4.5 <0.5",
|
|
19
|
+
"@opentui/solid": ">=0.4.5 <0.5",
|
|
20
|
+
"solid-js": ">=1.9.10 <2",
|
|
21
|
+
"zod": ">=3.23"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"elliot": {
|
|
27
|
+
"opencodeRange": ">=1.18.19 <1.19.0"
|
|
28
|
+
},
|
|
29
|
+
"oc-themes": [
|
|
30
|
+
"themes/elliot.json"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-command classification — the policy intelligence glob patterns cannot express.
|
|
3
|
+
*
|
|
4
|
+
* Ported (adapted, not copied wholesale) from `elliot-cli/src/tools/bash-policy.ts`,
|
|
5
|
+
* which is pure and dependency-free. Three things here are beyond what OpenCode's
|
|
6
|
+
* native `permission.bash` globs can represent, which is exactly why this file exists:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Category labels** (READ/TEST/BUILD/MUTATION/DESTRUCTIVE/UNKNOWN) — a glob
|
|
9
|
+
* yields a verdict, never a reason. A human approving a prompt deserves the reason.
|
|
10
|
+
* 2. **Chain-splitting with worst-case ranking** — OpenCode does decompose pipelines
|
|
11
|
+
* per command node (`tool/shell.ts:408`), but the *classification* of a compound
|
|
12
|
+
* command as "only as safe as its most dangerous segment" is ours.
|
|
13
|
+
* 3. **The credential-exfiltration heuristic** — a relationship between two tokens
|
|
14
|
+
* ("names a secret-shaped path" AND "invokes a network tool") in one command line.
|
|
15
|
+
* No prefix pattern can see a relationship.
|
|
16
|
+
*
|
|
17
|
+
* Failure direction is inherited deliberately from the original: over-trigger toward
|
|
18
|
+
* `ask`, never under-trigger. A spurious prompt is a cost; a missed bypass is an
|
|
19
|
+
* incident. Notably `CHAIN_SPLIT_RE` is a naive split, not a shell parser — a literal
|
|
20
|
+
* `;` inside a quoted commit message can cause an extra prompt. That is the correct
|
|
21
|
+
* trade.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here is repository-specific: every pattern is a generic command shape.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type Category = "READ" | "TEST" | "BUILD" | "MUTATION" | "DESTRUCTIVE" | "UNKNOWN"
|
|
27
|
+
|
|
28
|
+
function firstToken(cmd: string): string {
|
|
29
|
+
return cmd.trim().split(/\s+/)[0] ?? ""
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* git is classified by SUBCOMMAND and flags, never lumped together: `git status` and
|
|
34
|
+
* `git push --force` share a prefix and share nothing else.
|
|
35
|
+
*/
|
|
36
|
+
function classifyGit(cmd: string): Category {
|
|
37
|
+
const rest = cmd.trim().replace(/^git\s+/i, "")
|
|
38
|
+
const sub = firstToken(rest).toLowerCase()
|
|
39
|
+
|
|
40
|
+
if (/(^|\s)(--force|-f)(\s|$)/i.test(rest) && sub === "push") return "DESTRUCTIVE"
|
|
41
|
+
if (sub === "push") return "MUTATION"
|
|
42
|
+
if (sub === "reset" && /--hard/i.test(rest)) return "DESTRUCTIVE"
|
|
43
|
+
if (sub === "reset") return "MUTATION"
|
|
44
|
+
if (sub === "clean" && /-\w*f/i.test(rest)) return "DESTRUCTIVE"
|
|
45
|
+
if (sub === "clean") return "MUTATION"
|
|
46
|
+
if (["commit", "checkout", "merge", "rebase", "stash", "branch"].includes(sub)) {
|
|
47
|
+
// Listing branches is a read; deleting one is not.
|
|
48
|
+
if (sub === "branch") return /(^|\s)-[dD]\b/.test(rest) ? "MUTATION" : "READ"
|
|
49
|
+
return "MUTATION"
|
|
50
|
+
}
|
|
51
|
+
if (["status", "log", "diff", "show", "remote"].includes(sub)) return "READ"
|
|
52
|
+
return "UNKNOWN"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const READ_PATTERNS: RegExp[] = [
|
|
56
|
+
/^(ls|dir)\b/i, /^cat\b/i, /^type\b/i, /^head\b/i, /^tail\b/i, /^wc\b/i,
|
|
57
|
+
/^pwd$/i, /^cd\b/i, /^echo\b/i,
|
|
58
|
+
/^find\b(?!.*(-delete|-exec))/i,
|
|
59
|
+
/^(which|where)\b/i,
|
|
60
|
+
/^node\s+(-v|--version)$/i,
|
|
61
|
+
/^npm\s+(ls|list)\b/i,
|
|
62
|
+
// Read-only pipeline filters. Present because OpenCode asks about every node of a
|
|
63
|
+
// pipeline, so an unclassified filter would drag an otherwise-READ pipeline to UNKNOWN.
|
|
64
|
+
// `sed`/`awk`/`xargs` are deliberately absent: `sed -i` writes, `awk` has
|
|
65
|
+
// `print > file` and `system()`, `xargs` executes whatever follows.
|
|
66
|
+
/^(grep|findstr|cut|sort|uniq|tr|jq)\b/i,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const TEST_PATTERNS: RegExp[] = [
|
|
70
|
+
/^npm\s+test\b/i, /^npm\s+run\s+test/i, /^(yarn|pnpm)\s+test\b/i,
|
|
71
|
+
/^python\s+-m\s+pytest\b/i, /^pytest\b/i, /^jest\b/i, /^vitest\b/i,
|
|
72
|
+
/^npx\s+vitest\b/i, /^go\s+test\b/i, /^cargo\s+test\b/i, /^mvn\s+test\b/i,
|
|
73
|
+
/^dotnet\s+test\b/i, /^make\s+test\b/i,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const BUILD_PATTERNS: RegExp[] = [
|
|
77
|
+
/^npm\s+run\s+build\b/i, /^(yarn|pnpm)\s+build\b/i, /^tsc\b/i,
|
|
78
|
+
/^go\s+build\b/i, /^cargo\s+build\b/i, /^mvn\s+package\b/i, /^make(\s+build)?$/i,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const MUTATION_PATTERNS: RegExp[] = [
|
|
82
|
+
/^npm\s+(install|uninstall|ci)\b/i, /^pip\s+(install|uninstall)\b/i,
|
|
83
|
+
/^mv\b/i, /^cp\b/i, /^mkdir\b/i, /^touch\b/i, /^chmod\b/i, /^chown\b/i,
|
|
84
|
+
/^docker\s+(run|stop|rm)\b/i, /^kill\b/i, /^taskkill\b/i,
|
|
85
|
+
// A single, non-recursive delete — real but bounded. `rm -rf` is caught as
|
|
86
|
+
// DESTRUCTIVE before this list is consulted.
|
|
87
|
+
/^rm\b/i, /^del\b/i,
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Catastrophic and with no legitimate interactive use. Confirmation adds no safety
|
|
92
|
+
* against a runaway loop, so these are refused outright. Intentionally tiny — broad
|
|
93
|
+
* denial belongs to the DESTRUCTIVE default (`ask`), not here.
|
|
94
|
+
*/
|
|
95
|
+
const HARD_DENY_PATTERNS: RegExp[] = [
|
|
96
|
+
/:\(\)\s*\{\s*:\|\s*:&\s*\}\s*;\s*:/, // classic fork bomb
|
|
97
|
+
/^rm\s+-rf?\s+(\/|~)(\s|$)/i, // rm -rf / | rm -rf ~
|
|
98
|
+
/^rm\s+-rf?\s+\/\*(\s|$)/i, // rm -rf /*
|
|
99
|
+
/^rmdir\s+\/s\s+\/q\s+[a-z]:\\?\s*$/i, // rmdir /s /q C:\
|
|
100
|
+
/^mkfs\b/i,
|
|
101
|
+
/^format\s+[a-z]:/i, // Windows format C:
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
105
|
+
/^rm\s+-rf?\b/i, /^rmdir\s+\/s\b/i, /^del\b.*(\/f|\/s|\/q|\*)/i,
|
|
106
|
+
/\b(drop|truncate)\s+(table|database)\b/i, /\bdelete\s+from\b/i,
|
|
107
|
+
/^format\b/i, /^mkfs\b/i, /^dd\s+if=/i,
|
|
108
|
+
/^(shutdown|reboot|halt)\b/i, /^chmod\s+-R\s+777\s+\//i,
|
|
109
|
+
/(curl|wget)\b.*\|\s*(sh|bash|powershell)\b/i, // pipe remote script into a shell
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Process-kill tools, and the process names Elliot itself runs under.
|
|
114
|
+
*
|
|
115
|
+
* `eai` is an npm bin shim, so the launcher IS a `node.exe` process and the
|
|
116
|
+
* runtime is `opencode.exe`. That makes a by-name kill self-inflicted: asked to
|
|
117
|
+
* restart a dev stack, the agent reached for
|
|
118
|
+
*
|
|
119
|
+
* Get-Process node | Stop-Process -Force
|
|
120
|
+
*
|
|
121
|
+
* which matched its own host and terminated the session mid-command. Because
|
|
122
|
+
* `Stop-Process -Force` maps to TerminateProcess, no signal handler, `finally`
|
|
123
|
+
* or `exit` hook runs -- so the terminal was also left in mouse-reporting mode,
|
|
124
|
+
* flooding the next shell with escape sequences. It killed every unrelated
|
|
125
|
+
* `node.exe` on the machine too.
|
|
126
|
+
*
|
|
127
|
+
* Denied outright rather than asked, because the confirmation prompt does not
|
|
128
|
+
* convey the real consequence: someone approving "restart the project" is not
|
|
129
|
+
* agreeing to kill the session asking the question.
|
|
130
|
+
*/
|
|
131
|
+
// `spps` and bare `kill` are PowerShell aliases for Stop-Process, so the same
|
|
132
|
+
// self-kill works spelled either way; both are word-bounded, so `kill` matches
|
|
133
|
+
// only the standalone verb and not `taskkill`/`pkill`/`killall`.
|
|
134
|
+
const PROCESS_KILL_TOOL = /\b(Stop-Process|spps|taskkill|pkill|killall|kill)\b/i
|
|
135
|
+
|
|
136
|
+
/** Word-bounded so `node_exporter` and `mynode` are not caught. */
|
|
137
|
+
const AGENT_RUNTIME_NAME = /\b(node|bun|opencode|eai)(\.exe)?\b/i
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* An explicit numeric PID means the caller already identified one process, which
|
|
141
|
+
* is the safe way to do this and stays allowed.
|
|
142
|
+
*/
|
|
143
|
+
const SCOPED_BY_PID = /(-Id\b|\/PID\b)\s*:?\s*\d+/i
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* True when a command would kill processes by NAME and that name matches the
|
|
147
|
+
* runtime Elliot is running inside.
|
|
148
|
+
*
|
|
149
|
+
* Killing `uvicorn`, `vite` or any other dev process by name stays allowed --
|
|
150
|
+
* this is not a ban on process management, only on the shape that takes the
|
|
151
|
+
* agent down with it.
|
|
152
|
+
*/
|
|
153
|
+
export function killsAgentRuntime(command: string): boolean {
|
|
154
|
+
if (!PROCESS_KILL_TOOL.test(command)) return false
|
|
155
|
+
if (SCOPED_BY_PID.test(command)) return false
|
|
156
|
+
return AGENT_RUNTIME_NAME.test(command)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Secret-shaped paths and network tools — the two halves of the exfil heuristic. */
|
|
160
|
+
const SECRET_PATH_PATTERN = /(\.env\b|id_rsa|\.aws\b|\.ssh\b|credentials|\.pem\b|\.p12\b)/i
|
|
161
|
+
const NETWORK_TOOL_PATTERN = /\b(curl|wget|nc|ncat|scp|ftp|Invoke-WebRequest|Invoke-RestMethod)\b/i
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when one command line both names a secret-shaped path AND invokes a network
|
|
165
|
+
* tool. Best-effort by design: it cannot prove intent, and it is the one rule here
|
|
166
|
+
* that no glob pattern could ever approximate.
|
|
167
|
+
*/
|
|
168
|
+
export function looksLikeCredentialExfiltration(command: string): boolean {
|
|
169
|
+
return SECRET_PATH_PATTERN.test(command) && NETWORK_TOOL_PATTERN.test(command)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Classify ONE command with no shell chaining in it. */
|
|
173
|
+
function classifySingle(command: string): Category {
|
|
174
|
+
const cmd = command.trim()
|
|
175
|
+
if (!cmd) return "UNKNOWN"
|
|
176
|
+
|
|
177
|
+
if (/^git\b/i.test(cmd)) return classifyGit(cmd)
|
|
178
|
+
|
|
179
|
+
if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
|
|
180
|
+
if (DESTRUCTIVE_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
|
|
181
|
+
if (looksLikeCredentialExfiltration(cmd)) return "DESTRUCTIVE"
|
|
182
|
+
|
|
183
|
+
if (TEST_PATTERNS.some((re) => re.test(cmd))) return "TEST"
|
|
184
|
+
if (BUILD_PATTERNS.some((re) => re.test(cmd))) return "BUILD"
|
|
185
|
+
if (MUTATION_PATTERNS.some((re) => re.test(cmd))) return "MUTATION"
|
|
186
|
+
if (READ_PATTERNS.some((re) => re.test(cmd))) return "READ"
|
|
187
|
+
|
|
188
|
+
return "UNKNOWN"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How restrictive each category is — used to pick the worst segment of a chain. */
|
|
192
|
+
const RANK: Record<Category, number> = {
|
|
193
|
+
READ: 0, TEST: 0, BUILD: 0,
|
|
194
|
+
MUTATION: 1, UNKNOWN: 1,
|
|
195
|
+
DESTRUCTIVE: 2,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function worstOf(categories: Category[]): Category {
|
|
199
|
+
return categories.reduce((worst, c) => (RANK[c] > RANK[worst] ? c : worst))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Metacharacters that chain, pipe, substitute, or redirect — each hands control to a
|
|
204
|
+
* SECOND command the anchored (`^`) patterns above would never see. Without splitting,
|
|
205
|
+
* `git status ; rm -rf /` classifies as the READ head alone and the tail rides along.
|
|
206
|
+
*/
|
|
207
|
+
const CHAIN_SPLIT_RE = /(?:&&|\|\||>>|;|\||`|\$\(|\n|>|<|&)/
|
|
208
|
+
|
|
209
|
+
function segmentsOf(command: string): string[] {
|
|
210
|
+
return command.trim().split(CHAIN_SPLIT_RE).map((s) => s.trim()).filter(Boolean)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function classifyCommand(command: string): Category {
|
|
214
|
+
const cmd = command.trim()
|
|
215
|
+
if (!cmd) return "UNKNOWN"
|
|
216
|
+
|
|
217
|
+
// Whole-string checks first: a catastrophic or exfil shape is caught wherever it sits
|
|
218
|
+
// relative to a chaining operator.
|
|
219
|
+
if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
|
|
220
|
+
if (DESTRUCTIVE_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
|
|
221
|
+
if (looksLikeCredentialExfiltration(cmd)) return "DESTRUCTIVE"
|
|
222
|
+
// Checked whole-string: the kill tool and the process name routinely sit on
|
|
223
|
+
// opposite sides of a pipe, so per-segment classification would see neither.
|
|
224
|
+
if (killsAgentRuntime(cmd)) return "DESTRUCTIVE"
|
|
225
|
+
|
|
226
|
+
const segments = segmentsOf(cmd)
|
|
227
|
+
if (segments.length <= 1) return classifySingle(cmd)
|
|
228
|
+
|
|
229
|
+
// A compound command is only as safe as its most dangerous segment.
|
|
230
|
+
return worstOf(segments.map(classifySingle))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function isHardDenied(command: string): boolean {
|
|
234
|
+
const cmd = command.trim()
|
|
235
|
+
if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return true
|
|
236
|
+
// A hard-deny command can hide as one segment of a chain.
|
|
237
|
+
return segmentsOf(cmd).some((seg) => HARD_DENY_PATTERNS.some((re) => re.test(seg)))
|
|
238
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elliot token read + refresh — the credential half of the proxy provider.
|
|
3
|
+
*
|
|
4
|
+
* Ported (trimmed, not imported) from `elliot-cli/src/auth/refresh.ts` and
|
|
5
|
+
* `elliot-cli/src/config.ts`: same `~/.elliot/config.json` shape, same
|
|
6
|
+
* refresh endpoint contract (`POST {backend_url}/auth/refresh` ->
|
|
7
|
+
* `{access_token, expires_in_seconds}`), same "never throw" discipline. This
|
|
8
|
+
* file exists inside `opencode-plugin` rather than importing the CLI package
|
|
9
|
+
* because the plugin ships and is loaded independently of `elliot-cli` — see
|
|
10
|
+
* `docs/superpowers/plans/2026-09-03-eai-proxy-auth.md`.
|
|
11
|
+
*
|
|
12
|
+
* `freshToken` is the only export the proxy provider's `auth.loader`/`fetch`
|
|
13
|
+
* needs: it never throws, so a stale/missing/corrupt config degrades to "no
|
|
14
|
+
* token" (the proxy 401s) rather than crashing OpenCode's model call.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs"
|
|
17
|
+
import * as os from "node:os"
|
|
18
|
+
import * as path from "node:path"
|
|
19
|
+
|
|
20
|
+
export interface ElliotConfig {
|
|
21
|
+
jwt_token?: string
|
|
22
|
+
refresh_token?: string
|
|
23
|
+
backend_url?: string
|
|
24
|
+
access_token_expires_at?: string
|
|
25
|
+
[k: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `${ELLIOT_HOME}/config.json`, or `~/.elliot/config.json` when unset. */
|
|
29
|
+
export function elliotConfigPath(): string {
|
|
30
|
+
const home = process.env.ELLIOT_HOME ?? path.join(os.homedir(), ".elliot")
|
|
31
|
+
return path.join(home, "config.json")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parsed config, or null when absent, unreadable, or not valid JSON. */
|
|
35
|
+
export function readElliotConfig(): ElliotConfig | null {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(elliotConfigPath(), "utf-8")
|
|
38
|
+
return JSON.parse(raw) as ElliotConfig
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist the config, owner-only (0600) — this file holds a live bearer
|
|
46
|
+
* token. Creates `~/.elliot` (0700) if it doesn't exist yet.
|
|
47
|
+
*/
|
|
48
|
+
export function writeElliotConfig(cfg: ElliotConfig): void {
|
|
49
|
+
const configPath = elliotConfigPath()
|
|
50
|
+
const configDir = path.dirname(configPath)
|
|
51
|
+
if (!fs.existsSync(configDir)) {
|
|
52
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })
|
|
53
|
+
}
|
|
54
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), { mode: 0o600 })
|
|
55
|
+
fs.chmodSync(configPath, 0o600)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `exp` (seconds since epoch) from a JWT's middle segment, or null. */
|
|
59
|
+
function decodeJwtExp(token: string): number | null {
|
|
60
|
+
try {
|
|
61
|
+
const parts = token.split(".")
|
|
62
|
+
if (parts.length < 2) return null
|
|
63
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"))
|
|
64
|
+
return typeof payload?.exp === "number" ? payload.exp : null
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const REFRESH_SKEW_MS = 60_000
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True if the access token is expired or within `REFRESH_SKEW_MS` of
|
|
74
|
+
* expiring. Prefers the stored `access_token_expires_at`; falls back to
|
|
75
|
+
* decoding `jwt_token`'s `exp` claim; true (treat as expired -> refresh) when
|
|
76
|
+
* neither is decodable.
|
|
77
|
+
*/
|
|
78
|
+
export function isExpired(cfg: ElliotConfig, nowMs = Date.now()): boolean {
|
|
79
|
+
if (cfg.access_token_expires_at) {
|
|
80
|
+
const expiresAtMs = new Date(cfg.access_token_expires_at).getTime()
|
|
81
|
+
if (Number.isFinite(expiresAtMs)) {
|
|
82
|
+
return expiresAtMs - REFRESH_SKEW_MS <= nowMs
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const exp = cfg.jwt_token ? decodeJwtExp(cfg.jwt_token) : null
|
|
86
|
+
if (exp === null) return true
|
|
87
|
+
return exp * 1000 - REFRESH_SKEW_MS <= nowMs
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RefreshResponse {
|
|
91
|
+
access_token: string
|
|
92
|
+
expires_in_seconds: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface FreshTokenDeps {
|
|
96
|
+
fetchImpl?: typeof fetch
|
|
97
|
+
write?: (cfg: ElliotConfig) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A valid access token, refreshing + persisting when expired and a
|
|
102
|
+
* `refresh_token`/`backend_url` are present. NEVER throws: any failure
|
|
103
|
+
* (network, non-2xx, malformed response) falls back to the existing
|
|
104
|
+
* `jwt_token` — the proxy 401s on a stale token rather than this crashing the
|
|
105
|
+
* model call.
|
|
106
|
+
*/
|
|
107
|
+
export async function freshToken(
|
|
108
|
+
cfg: ElliotConfig,
|
|
109
|
+
deps?: FreshTokenDeps,
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
try {
|
|
112
|
+
if (cfg.jwt_token && !isExpired(cfg)) return cfg.jwt_token
|
|
113
|
+
if (!cfg.refresh_token || !cfg.backend_url) return cfg.jwt_token ?? null
|
|
114
|
+
|
|
115
|
+
const fetchImpl = deps?.fetchImpl ?? fetch
|
|
116
|
+
const write = deps?.write ?? writeElliotConfig
|
|
117
|
+
|
|
118
|
+
const response = await fetchImpl(`${cfg.backend_url}/auth/refresh`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ refresh_token: cfg.refresh_token }),
|
|
122
|
+
})
|
|
123
|
+
if (!response.ok) return cfg.jwt_token ?? null
|
|
124
|
+
|
|
125
|
+
const data = (await response.json()) as RefreshResponse
|
|
126
|
+
cfg.jwt_token = data.access_token
|
|
127
|
+
cfg.access_token_expires_at = new Date(Date.now() + data.expires_in_seconds * 1000).toISOString()
|
|
128
|
+
write(cfg)
|
|
129
|
+
return data.access_token
|
|
130
|
+
} catch {
|
|
131
|
+
return cfg.jwt_token ?? null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make the agent call itself Elliot-AI.
|
|
3
|
+
*
|
|
4
|
+
* The visual layer was branded long before this: wordmark, palette, terminal
|
|
5
|
+
* title. But the agent's own sense of identity lives in the system prompt, and
|
|
6
|
+
* on a stock runtime that prompt opens with "You are OpenCode, ...". So asking
|
|
7
|
+
* the CLI "who are you?" got "I'm opencode" — every surface said Elliot except
|
|
8
|
+
* the one the user actually talks to.
|
|
9
|
+
*
|
|
10
|
+
* Fixed here rather than in the prompt files, because those are compiled into
|
|
11
|
+
* the stock upstream binary and patching them would put Elliot back inside
|
|
12
|
+
* OpenCode. `experimental.chat.system.transform` is upstream's own hook for
|
|
13
|
+
* exactly this (`packages/plugin/src/index.ts`), fired from both
|
|
14
|
+
* `agent/agent.ts` and `session/llm/request.ts`, so the fix stays in the
|
|
15
|
+
* overlay and survives an upstream upgrade.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The product name the assistant should use for itself. */
|
|
19
|
+
export const PRODUCT_NAME = "Elliot-AI"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Matches ONLY the identity clause that opens a system prompt.
|
|
23
|
+
*
|
|
24
|
+
* Anchored to the start and requiring the following comma, because the prompts
|
|
25
|
+
* also contain `https://opencode.ai/docs` and
|
|
26
|
+
* `https://github.com/anomalyco/opencode`. Those are real upstream links: a
|
|
27
|
+
* blanket rename would silently turn working URLs into 404s, and would also
|
|
28
|
+
* corrupt references to `opencode.json` and `.opencode/` that the tool guidance
|
|
29
|
+
* depends on. Eight identity variants exist across the prompt files (`You are
|
|
30
|
+
* OpenCode,` / `You are opencode,`), and all of them share this shape.
|
|
31
|
+
*/
|
|
32
|
+
const IDENTITY = /^You are opencode,/i
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prepended so the model can answer "what is your name" and "who made you"
|
|
36
|
+
* without inferring it from a rewritten sentence halfway down a long prompt.
|
|
37
|
+
*
|
|
38
|
+
* It states only what is true. The underlying model is deliberately not named:
|
|
39
|
+
* that is a provider detail that changes, and guessing it is how an assistant
|
|
40
|
+
* ends up confidently telling a customer something wrong.
|
|
41
|
+
*/
|
|
42
|
+
const IDENTITY_PREAMBLE = [
|
|
43
|
+
`You are ${PRODUCT_NAME}, the coding agent made by Elliot Systems.`,
|
|
44
|
+
`When asked who or what you are, say you are ${PRODUCT_NAME} by Elliot Systems.`,
|
|
45
|
+
`Never refer to yourself as OpenCode.`,
|
|
46
|
+
`OpenCode is the open-source harness you run on, not your identity. If a user`,
|
|
47
|
+
`asks directly what you are built on, you may say you are built on OpenCode.`,
|
|
48
|
+
].join(" ")
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Rewrite the system prompt in place.
|
|
52
|
+
*
|
|
53
|
+
* Idempotent: re-running leaves the result unchanged, which matters because the
|
|
54
|
+
* hook fires per request and on a long session would otherwise stack the
|
|
55
|
+
* preamble dozens of times and eat the context window.
|
|
56
|
+
*/
|
|
57
|
+
export function applyElliotIdentity(system: string[]): string[] {
|
|
58
|
+
if (system.some((entry) => entry.startsWith(IDENTITY_PREAMBLE))) return system
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < system.length; i++) {
|
|
61
|
+
const entry = system[i]
|
|
62
|
+
if (typeof entry === "string" && IDENTITY.test(entry)) {
|
|
63
|
+
system[i] = entry.replace(IDENTITY, `You are ${PRODUCT_NAME},`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
system.unshift(IDENTITY_PREAMBLE)
|
|
68
|
+
return system
|
|
69
|
+
}
|