@elliotsystems/eai 0.1.2 → 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.
@@ -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
+ }