@elliotsystems/eai 0.1.1 → 0.1.3
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 +51 -5
- package/bin/eai +32 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +42 -4
- package/lib/stage.js +116 -0
- package/package.json +9 -8
- 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/index.ts +199 -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
|
@@ -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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @elliotsystems/opencode-plugin — Elliot AI's OpenCode integration.
|
|
3
|
+
*
|
|
4
|
+
* Phase C2 scope: exactly one job. The Elliot backend serves `knowledge_search`
|
|
5
|
+
* over the native MCP + OAuth boundary (agent-visible as
|
|
6
|
+
* `elliot_knowledge_search`); this plugin injects the session's repository
|
|
7
|
+
* scope into that tool's arguments so retrieval can be repo-focused — while the
|
|
8
|
+
* model itself never names a repository, and the backend's `resolve_repo_scope`
|
|
9
|
+
* remains the only authorizer.
|
|
10
|
+
*
|
|
11
|
+
* What this plugin deliberately does NOT do:
|
|
12
|
+
* - no tokens: OAuth lifecycle (login, storage, refresh) is OpenCode's native
|
|
13
|
+
* MCP client, verified end-to-end against this backend;
|
|
14
|
+
* - no transport: there is no tool-invoke server route at v1.18.19, and
|
|
15
|
+
* reading mcp-auth.json would create a second credential path;
|
|
16
|
+
* - no prompt stuffing: knowledge arrives when the agent elects the tool.
|
|
17
|
+
*
|
|
18
|
+
* Loading (any of): opencode.json `"plugin": [["@elliotsystems/opencode-plugin",
|
|
19
|
+
* {"serverNames": ["elliot"]}]]`, a path spec during development, or a
|
|
20
|
+
* `.opencode/plugin/` file re-export.
|
|
21
|
+
*/
|
|
22
|
+
import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
|
|
23
|
+
import { ELLIOT_PROVIDER_ID, configHook, makeAuthHook } from "./proxy-provider.js"
|
|
24
|
+
import { readElliotConfig } from "./elliot-auth.js"
|
|
25
|
+
import { formatScope, scopeForWorktree } from "./scope.js"
|
|
26
|
+
|
|
27
|
+
// Structural mirrors of the host types (packages/plugin/src/index.ts @
|
|
28
|
+
// v1.18.19). Declared locally so this package type-checks and unit-tests
|
|
29
|
+
// without the opencode workspace on the module path; the host validates
|
|
30
|
+
// plugins structurally (`readV1Plugin`), so compatibility is by shape, not by
|
|
31
|
+
// nominal import.
|
|
32
|
+
export interface PluginInput {
|
|
33
|
+
directory: string
|
|
34
|
+
worktree: string
|
|
35
|
+
project?: unknown
|
|
36
|
+
client?: {
|
|
37
|
+
auth?: {
|
|
38
|
+
set: (input: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
[k: string]: unknown
|
|
41
|
+
}
|
|
42
|
+
serverUrl?: URL
|
|
43
|
+
$?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The host's Permission payload — only the fields this plugin reads. */
|
|
47
|
+
export interface PermissionInput {
|
|
48
|
+
type: string
|
|
49
|
+
pattern?: string | string[]
|
|
50
|
+
metadata?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Structural mirror of the host's `AuthHook` (packages/plugin/src/index.ts). */
|
|
54
|
+
export interface AuthHook {
|
|
55
|
+
provider: string
|
|
56
|
+
methods: unknown[]
|
|
57
|
+
loader?: (auth: () => Promise<unknown>, provider: unknown) => Promise<Record<string, any>>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Hooks {
|
|
61
|
+
"tool.execute.before"?: (
|
|
62
|
+
input: { tool: string; sessionID: string; callID: string },
|
|
63
|
+
output: { args: Record<string, unknown> },
|
|
64
|
+
) => Promise<void>
|
|
65
|
+
"permission.ask"?: (
|
|
66
|
+
input: PermissionInput,
|
|
67
|
+
output: { status: "ask" | "deny" | "allow" },
|
|
68
|
+
) => Promise<void>
|
|
69
|
+
config?: (input: any) => Promise<void>
|
|
70
|
+
auth?: AuthHook
|
|
71
|
+
dispose?: () => Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
|
|
75
|
+
|
|
76
|
+
export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
77
|
+
// Fail loudly at load on bad options — a plugin that limps with a typo'd
|
|
78
|
+
// config is worse than one that says why it refused to start.
|
|
79
|
+
const options = parseOptions(rawOptions)
|
|
80
|
+
|
|
81
|
+
// Session truth, resolved once. The worktree comes from OpenCode itself —
|
|
82
|
+
// the one source the model cannot influence.
|
|
83
|
+
const worktree = input.worktree || input.directory
|
|
84
|
+
|
|
85
|
+
// Seed a placeholder auth entry for the `elliot` provider so the host's
|
|
86
|
+
// auth loader fires at all (`provider.ts:1584` skips the loader entirely
|
|
87
|
+
// when nothing is stored for its provider id — see Task-1 findings). The
|
|
88
|
+
// value itself is never the credential that reaches the wire; the auth
|
|
89
|
+
// loader's `fetch` attaches the live bearer token per request. Guarded so
|
|
90
|
+
// a client-RPC failure never breaks plugin init, and skipped outright when
|
|
91
|
+
// the user isn't logged into Elliot.
|
|
92
|
+
if (readElliotConfig()) {
|
|
93
|
+
try {
|
|
94
|
+
await input.client?.auth?.set({
|
|
95
|
+
path: { id: ELLIOT_PROVIDER_ID },
|
|
96
|
+
body: { type: "api", key: "elliot-managed" },
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
// Never break plugin init on a seeding failure — the auth loader
|
|
100
|
+
// simply won't fire this session, same as the no-login path.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
/**
|
|
106
|
+
* One handler, two jobs — a JS object literal may carry only one
|
|
107
|
+
* `tool.execute.before` key, and a second would silently shadow the first.
|
|
108
|
+
* (a) the Elliot knowledge tool -> inject worktree-derived repo scope
|
|
109
|
+
* (b) the shell tool -> enforce org hard-deny / credential policy
|
|
110
|
+
*/
|
|
111
|
+
"tool.execute.before": async (hookInput, output) => {
|
|
112
|
+
// (a) Knowledge-tool scope injection. The model never names a repository;
|
|
113
|
+
// this value comes from the session worktree, and the backend authorizes it.
|
|
114
|
+
if (isElliotKnowledgeTool(hookInput.tool, options)) {
|
|
115
|
+
const scope = await scopeForWorktree(worktree)
|
|
116
|
+
if (scope) {
|
|
117
|
+
// Unconditional overwrite: session truth beats anything already in args.
|
|
118
|
+
// The backend schema forbids model-supplied scope, but if one ever slipped
|
|
119
|
+
// through a future contract change, it dies here first.
|
|
120
|
+
output.args["repo_scope_hint"] = formatScope(scope)
|
|
121
|
+
} else {
|
|
122
|
+
// No derivable scope -> guarantee none is smuggled either. Tenant-wide
|
|
123
|
+
// search is the correct degradation (authorized, just unfocused).
|
|
124
|
+
delete output.args["repo_scope_hint"]
|
|
125
|
+
}
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// (b) Shell policy, enforced HERE rather than in `permission.ask`.
|
|
130
|
+
//
|
|
131
|
+
// Forced by upstream reality, verified by scanning every `plugin.trigger(...)`
|
|
132
|
+
// site in v1.18.19: thirteen hooks are triggered and `permission.ask` is NOT
|
|
133
|
+
// among them. It is declared in the public plugin API
|
|
134
|
+
// (packages/plugin/src/index.ts:261) but never invoked, so a handler there is
|
|
135
|
+
// dead code in this version. `tool.execute.before` IS triggered
|
|
136
|
+
// (session/prompt.ts), and throwing from it aborts the tool call — the only
|
|
137
|
+
// plugin-side hard stop available today.
|
|
138
|
+
//
|
|
139
|
+
// Why bother when the org glob policy already asks/denies? Defence in depth
|
|
140
|
+
// against what globs structurally cannot see: a catastrophic shape hiding
|
|
141
|
+
// behind a safe-looking chain segment (`git status ; rm -rf /`), and a command
|
|
142
|
+
// that both names a secret-shaped path and invokes a network tool. If an org
|
|
143
|
+
// policy is ever loosened by accident, these still do not get through.
|
|
144
|
+
//
|
|
145
|
+
// Deliberately narrow: it only ever BLOCKS, and only on the hard-deny and
|
|
146
|
+
// credential classes. Commands the classifier merely wants confirmed are left
|
|
147
|
+
// to the host's own ask flow, which already handles them well.
|
|
148
|
+
if (hookInput.tool !== "bash" && !hookInput.tool.endsWith("_bash")) return
|
|
149
|
+
|
|
150
|
+
const command = output.args?.["command"]
|
|
151
|
+
if (typeof command !== "string") return
|
|
152
|
+
|
|
153
|
+
const verdict = shellVerdict(command)
|
|
154
|
+
if (verdict?.status !== "deny") return
|
|
155
|
+
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Blocked by Elliot organization policy: ${verdict.reason}. Command: ${command}`,
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Kept deliberately, though currently INERT: `permission.ask` has no trigger
|
|
163
|
+
* site in v1.18.19 (verified across every `plugin.trigger` call). When upstream
|
|
164
|
+
* wires it, policy decisions belong here — a returned status is cleaner than a
|
|
165
|
+
* thrown tool error — so this is the forward-compatible home. It can only ever
|
|
166
|
+
* tighten.
|
|
167
|
+
*/
|
|
168
|
+
"permission.ask": async (permInput, output) => {
|
|
169
|
+
if (permInput.type !== "bash") return
|
|
170
|
+
const command = permInput.metadata?.["command"]
|
|
171
|
+
if (typeof command !== "string") return
|
|
172
|
+
const verdict = shellVerdict(command)
|
|
173
|
+
if (!verdict) return
|
|
174
|
+
if (output.status === "deny") return // never loosen
|
|
175
|
+
output.status = verdict.status
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Registers the `elliot` provider (proxy baseURL) and defaults the
|
|
180
|
+
* model to it — no-op when the user isn't logged into Elliot. See
|
|
181
|
+
* `proxy-provider.ts`.
|
|
182
|
+
*/
|
|
183
|
+
config: async (c) => {
|
|
184
|
+
configHook(c)
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Self-refreshing auth loader for the `elliot` provider — see
|
|
189
|
+
* `proxy-provider.ts`. Only fires once the seeding above has stored an
|
|
190
|
+
* auth entry for `ELLIOT_PROVIDER_ID`.
|
|
191
|
+
*/
|
|
192
|
+
auth: makeAuthHook(),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Both export styles the host loader accepts (`getLegacyPlugins` takes any
|
|
197
|
+
// function export; `PluginModule` prefers `server`).
|
|
198
|
+
export const server = ElliotPlugin
|
|
199
|
+
export default ElliotPlugin
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How OpenCode composes agent-visible MCP tool keys.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, with ZERO imports, because two runtimes need it. `policy.ts`
|
|
5
|
+
* uses it in the server plugin (where zod is available) and `tui/knowledge.ts`
|
|
6
|
+
* uses it in the TUI plugin runtime — which shares only a fixed list of modules
|
|
7
|
+
* with plugins (`solid-js`, `@opentui/*`, `bun`; see `tui/host.d.ts`) and would
|
|
8
|
+
* fail to load a module that reached for `zod` transitively.
|
|
9
|
+
*
|
|
10
|
+
* Duplicating the rule in both places was the alternative. A drifted copy of a
|
|
11
|
+
* key-composition rule fails silently — the tool simply stops being recognised —
|
|
12
|
+
* so one definition, imported twice, is the safer shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mirror of OpenCode's `McpCatalog.sanitize` (mcp/catalog.ts:117) so composed
|
|
17
|
+
* keys are predicted exactly, including server names carrying characters MCP
|
|
18
|
+
* sanitises away.
|
|
19
|
+
*/
|
|
20
|
+
export function sanitizeKey(value: string): string {
|
|
21
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The backend tool behind every Elliot knowledge lookup. */
|
|
25
|
+
export const KNOWLEDGE_TOOL = "knowledge_search"
|
|
26
|
+
|
|
27
|
+
/** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server. */
|
|
28
|
+
export function knowledgeToolKey(serverName: string): string {
|
|
29
|
+
return `${sanitizeKey(serverName)}_${KNOWLEDGE_TOOL}`
|
|
30
|
+
}
|