opencode-shell-safety 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 (3) hide show
  1. package/README.md +88 -0
  2. package/package.json +45 -0
  3. package/src/index.ts +504 -0
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # opencode-shell-safety
2
+
3
+ An OpenCode v2 plugin that uses [Jev](https://en.wikipedia.org/wiki/Jev_(AI_model))
4
+ to classify shell permission requests against the active agent definition and
5
+ its filesystem policy.
6
+
7
+ ## Install with OpenCode Zen
8
+
9
+ Add the plugin to `~/.config/opencode/opencode.jsonc` to use it across projects.
10
+ OpenCode installs configured package plugins; you do not need to install the
11
+ package separately. For one project, use its `opencode.jsonc` instead.
12
+
13
+ ```jsonc
14
+ {
15
+ "plugins": [
16
+ {
17
+ "package": "opencode-shell-safety",
18
+ "options": {
19
+ "endpoint": "https://opencode.ai/zen/v1/systemone",
20
+ "model": "jev-1.13",
21
+ "integration": "opencode",
22
+ "agents": {
23
+ "Explorer": {
24
+ "enabled": true
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ For a manual key instead, replace `integration` with
34
+ `"apiKeyEnv": "OPENCODE_API_KEY"`.
35
+
36
+ ## Use TypeSafe AI
37
+
38
+ Get an API key from [TypeSafe AI](https://docs.typesafe.ai/introduction/quickstart)
39
+ and set `TYPESAFE_API_KEY` in the OpenCode server environment. Add this plugin
40
+ configuration to `opencode.jsonc` instead of the OpenCode Zen example above:
41
+
42
+ ```jsonc
43
+ {
44
+ "plugins": [
45
+ {
46
+ "package": "opencode-shell-safety",
47
+ "options": {
48
+ "endpoint": "https://api.typesafe.ai/v1/systemone",
49
+ "model": "jev-1.13.0",
50
+ "apiKeyEnv": "TYPESAFE_API_KEY",
51
+ "agents": {
52
+ "Explorer": {
53
+ "enabled": true
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ ## Options
63
+
64
+ | Option | Description |
65
+ | --- | --- |
66
+ | `endpoint` | SystemOne HTTP endpoint. |
67
+ | `model` | Model sent in each classification request. |
68
+ | `integration` | OpenCode integration whose credential takes precedence over `apiKeyEnv`. |
69
+ | `apiKeyEnv` | Environment variable to read if no integration credential is available. |
70
+ | `allowProbability` | Minimum `withinPolicy` probability required to allow a command. |
71
+ | `violationProbability` | Probability at or above which a filesystem, remote-mutation, or credential violation denies a command. |
72
+ | `timeoutMs` | Timeout for one request attempt. |
73
+ | `maxAttempts` | Maximum attempts for network, timeout, rate-limit, and server failures. |
74
+ | `retryDelayMs` | Delay between retry attempts. |
75
+ | `cache.capacity` | Maximum number of cached classification results. |
76
+ | `cache.ttlMs` | Successful classification cache lifetime in milliseconds. |
77
+ | `agents.<name>.enabled` | Enables classification for an agent. |
78
+ | `agents.<name>.thresholds` | Optional per-agent probability thresholds. |
79
+ | `agents.<name>.http` | Optional. Omission permits no HTTP methods or credential hosts. |
80
+ | `agents.<name>.http.methods` | HTTP methods the policy permits. |
81
+ | `agents.<name>.http.credentials` | Environment credential names mapped to allowed HTTPS hosts. |
82
+
83
+ Set at least one of `integration` or `apiKeyEnv`.
84
+
85
+ The plugin sends the selected credential to `endpoint`. Check the URL when
86
+ configuring a custom endpoint.
87
+
88
+ See [development.md](docs/development.md) for local tests and publishing.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-shell-safety",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that classifies shell permission requests against agent policy.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "check": "tsc --noEmit",
15
+ "test": "bun test test/index.test.ts",
16
+ "test:e2e": "bun test ./test/shell-safety.e2e.ts --timeout 120000",
17
+ "prepublishOnly": "bun run check && bun run test"
18
+ },
19
+ "dependencies": {
20
+ "@opencode/plugin": "2.0.12",
21
+ "effect": "4.0.0-rc.112"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "1.4.2",
25
+ "@types/json-schema": "7.0.15",
26
+ "typescript": "5.9.3"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/alexandru/opencode-shell-safety.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/alexandru/opencode-shell-safety/issues"
34
+ },
35
+ "homepage": "https://github.com/alexandru/opencode-shell-safety#readme",
36
+ "keywords": [
37
+ "opencode",
38
+ "plugin",
39
+ "shell",
40
+ "security"
41
+ ],
42
+ "engines": {
43
+ "bun": ">=1.4.2"
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,504 @@
1
+ import { Agent, Plugin } from "@opencode/plugin/effect"
2
+ import { Cache, Config, Effect, Exit, Redacted, Schedule, Schema, String as EffectString } from "effect"
3
+
4
+ export type PermissionEffect = "allow" | "ask" | "deny"
5
+
6
+ export type PermissionEvent = {
7
+ readonly sessionID: string
8
+ readonly agent?: string
9
+ readonly action: string
10
+ readonly resources: ReadonlyArray<string>
11
+ effect: PermissionEffect
12
+ message?: string
13
+ }
14
+
15
+ type FilesystemPermissionRule = {
16
+ readonly resource: string
17
+ readonly effect: PermissionEffect
18
+ }
19
+
20
+ type ResolvedAgentDefinition = {
21
+ readonly encoded: string
22
+ readonly externalDirectoryRules: ReadonlyArray<FilesystemPermissionRule>
23
+ }
24
+
25
+ export type AgentDefinitionResolver = (agent: string) => Effect.Effect<ResolvedAgentDefinition | undefined>
26
+
27
+ export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
28
+
29
+ const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
30
+
31
+ const ThresholdsSchema = Schema.Struct({
32
+ allowProbability: Probability,
33
+ violationProbability: Probability,
34
+ })
35
+
36
+ const HttpPolicySchema = Schema.Struct({
37
+ methods: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
38
+ credentials: Schema.Record(Schema.String, Schema.Array(Schema.String))
39
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))),
40
+ })
41
+
42
+ const AgentPolicySchema = Schema.Struct({
43
+ enabled: Schema.Boolean,
44
+ thresholds: Schema.optionalKey(ThresholdsSchema),
45
+ http: HttpPolicySchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({ methods: [], credentials: {} }))),
46
+ })
47
+
48
+ const CacheOptionsSchema = Schema.Struct({
49
+ capacity: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10_000 }))
50
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed(256))),
51
+ ttlMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 86_400_000 }))
52
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed(300_000))),
53
+ })
54
+
55
+ export const OptionsSchema = Schema.Struct({
56
+ endpoint: Schema.String,
57
+ model: Schema.String,
58
+ integration: Schema.optionalKey(Schema.String),
59
+ apiKeyEnv: Schema.optionalKey(Schema.String),
60
+ allowProbability: Probability.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0.45))),
61
+ violationProbability: Probability.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0.4))),
62
+ timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 60_000 }))
63
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed(15_000))),
64
+ maxAttempts: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 }))
65
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed(2))),
66
+ retryDelayMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 10_000 }))
67
+ .pipe(Schema.withDecodingDefaultKey(Effect.succeed(250))),
68
+ cache: CacheOptionsSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))),
69
+ agents: Schema.Record(Schema.String, AgentPolicySchema),
70
+ }).check(Schema.makeFilter((options) =>
71
+ options.integration || options.apiKeyEnv
72
+ ? undefined
73
+ : { path: ["apiKeyEnv"], issue: "integration or apiKeyEnv is required" }
74
+ ))
75
+
76
+ export type Options = typeof OptionsSchema.Type
77
+ export type AgentPolicy = typeof AgentPolicySchema.Type
78
+ export type Thresholds = typeof ThresholdsSchema.Type
79
+
80
+ const NoulAnswerSchema = Schema.Struct({
81
+ type: Schema.Literal("noul"),
82
+ noul: Probability,
83
+ })
84
+
85
+ const JevResponseSchema = Schema.Struct({
86
+ answers: Schema.Struct({
87
+ withinPolicy: NoulAnswerSchema,
88
+ filesystemViolation: NoulAnswerSchema,
89
+ remoteMutation: NoulAnswerSchema,
90
+ credentialViolation: NoulAnswerSchema,
91
+ }),
92
+ })
93
+ const JevResponseJsonSchema = Schema.fromJsonString(JevResponseSchema)
94
+
95
+ export type SafetyAssessment = typeof JevResponseSchema.Type["answers"]
96
+
97
+ class JevNetworkError extends Schema.TaggedError<JevNetworkError>()("JevNetworkError", {
98
+ message: Schema.String,
99
+ }) {}
100
+
101
+ class JevHttpError extends Schema.TaggedError<JevHttpError>()("JevHttpError", {
102
+ status: Schema.Number,
103
+ }) {}
104
+
105
+ class JevTimeoutError extends Schema.TaggedError<JevTimeoutError>()("JevTimeoutError", {
106
+ timeoutMs: Schema.Number,
107
+ }) {}
108
+
109
+ class JevDecodeError extends Schema.TaggedError<JevDecodeError>()("JevDecodeError", {
110
+ message: Schema.String,
111
+ }) {}
112
+
113
+ type JevRequestError = JevNetworkError | JevHttpError | JevTimeoutError | JevDecodeError
114
+
115
+ type ClassificationResult =
116
+ | { readonly ok: true; readonly assessment: SafetyAssessment }
117
+ | { readonly ok: false; readonly error: JevRequestError }
118
+
119
+ type NoulQuestion = {
120
+ readonly type: "noul"
121
+ readonly instructions: string
122
+ readonly criteria: {
123
+ readonly true: string
124
+ readonly false: string
125
+ }
126
+ }
127
+
128
+ type SystemOneRequest = {
129
+ readonly model: string
130
+ readonly state: {
131
+ readonly agent: string
132
+ readonly action: string
133
+ readonly projectDirectory: string
134
+ readonly shellCommand: ReadonlyArray<string>
135
+ readonly agentDefinition: string
136
+ readonly filesystemAccess: {
137
+ readonly projectDirectory: string
138
+ readonly externalDirectoryRules: ReadonlyArray<FilesystemPermissionRule>
139
+ }
140
+ readonly policy: AgentPolicy
141
+ }
142
+ readonly questions: {
143
+ readonly withinPolicy: NoulQuestion
144
+ readonly filesystemViolation: NoulQuestion
145
+ readonly remoteMutation: NoulQuestion
146
+ readonly credentialViolation: NoulQuestion
147
+ }
148
+ }
149
+
150
+ const OptionsJsonSchema = Schema.fromJsonString(OptionsSchema)
151
+ const AgentJsonSchema = Schema.fromJsonString(Agent.Info)
152
+
153
+ export const decodeOptions = (input: string) => Schema.decodeUnknownEffect(OptionsJsonSchema)(input)
154
+
155
+ export const encodeAgentDefinition = (agent: Agent.Info): string => Schema.encodeSync(AgentJsonSchema)(agent)
156
+
157
+ export const resolveAgentDefinition = (agent: Agent.Info): ResolvedAgentDefinition => ({
158
+ encoded: encodeAgentDefinition(agent),
159
+ externalDirectoryRules: agent.permissions
160
+ .filter((rule) => rule.action === "external_directory")
161
+ .map((rule) => ({ resource: rule.resource, effect: rule.effect })),
162
+ })
163
+
164
+ export const permissionFromAssessment = (
165
+ assessment: SafetyAssessment,
166
+ thresholds: Thresholds,
167
+ ): PermissionEffect => {
168
+ const hasViolation =
169
+ assessment.filesystemViolation.noul >= thresholds.violationProbability ||
170
+ assessment.remoteMutation.noul >= thresholds.violationProbability ||
171
+ assessment.credentialViolation.noul >= thresholds.violationProbability
172
+ return assessment.withinPolicy.noul >= thresholds.allowProbability && !hasViolation ? "allow" : "deny"
173
+ }
174
+
175
+ const noulQuestion = (instructions: string, yes: string, no: string): NoulQuestion => ({
176
+ type: "noul",
177
+ instructions,
178
+ criteria: { true: yes, false: no },
179
+ })
180
+
181
+ const questionPrompt = (value: string): string => EffectString.stripMargin(value).trim()
182
+
183
+ const responseBody = (response: Response): Effect.Effect<string, JevNetworkError | JevHttpError> => {
184
+ if (!response.ok) return Effect.fail(new JevHttpError({ status: response.status }))
185
+ return Effect.tryPromise({
186
+ try: () => response.text(),
187
+ catch: () => new JevNetworkError({ message: "response body read failed" }),
188
+ })
189
+ }
190
+
191
+ export const buildRequest = (
192
+ event: PermissionEvent & { readonly agent: string },
193
+ model: string,
194
+ policy: AgentPolicy,
195
+ agentDefinition: ResolvedAgentDefinition,
196
+ projectDirectory: string,
197
+ ): SystemOneRequest => ({
198
+ model,
199
+ state: {
200
+ agent: event.agent,
201
+ action: event.action,
202
+ projectDirectory,
203
+ shellCommand: event.resources,
204
+ agentDefinition: agentDefinition.encoded,
205
+ filesystemAccess: {
206
+ projectDirectory,
207
+ externalDirectoryRules: agentDefinition.externalDirectoryRules,
208
+ },
209
+ policy,
210
+ },
211
+ questions: {
212
+ withinPolicy: noulQuestion(
213
+ questionPrompt(`
214
+ |Read "state.agentDefinition" as JSON.
215
+ |
216
+ |Context:
217
+ |- Its "system" field is the agent's full system prompt.
218
+ |- Its other fields are the complete resolved agent configuration.
219
+ |- "state.projectDirectory" is the current project directory.
220
+ |- "state.filesystemAccess.externalDirectoryRules" is the complete ordered list of path permissions outside the project directory.
221
+ |
222
+ |Rules:
223
+ |- Decide whether "shellCommand" complies with those instructions and with "state.policy".
224
+ |- OpenCode invokes this classifier only after permission resolution returns "ask".
225
+ |- An "ask" rule means this classifier must decide from the command's actual effects; it is not a violation.
226
+ |- Shell deny rules remain final.
227
+ |- Apply ordered permission rules with the last matching rule winning.
228
+ |- Work explicitly assigned by the full system prompt is within policy when the command performs that work and its effects satisfy all permissions.
229
+ |- A Bash command may access only project paths and external paths allowed by the last matching rule in "state.filesystemAccess.externalDirectoryRules".
230
+ |- Apply that path restriction to every filesystem read and write, regardless of whether the command itself is read-only.
231
+ |- Treat the exact path "/dev/null" as a shell output sink, not as a filesystem access or write. Do not extend this exception to any other path under "/dev".
232
+ |- "state.policy.http" is additional explicit authorization: an HTTP request is authorized when its method is listed and every environment credential is sent by HTTPS only to a host listed for that credential, unless another agent instruction forbids the operation.
233
+ |
234
+ |Does "shellCommand" comply with the full system prompt, resolved agent configuration, and plugin policy?
235
+ `),
236
+ "The complete command complies with the full system prompt, resolved agent configuration, and plugin policy.",
237
+ "The command violates an instruction or permission, or its effects cannot be determined.",
238
+ ),
239
+ filesystemViolation: noulQuestion(
240
+ questionPrompt(`
241
+ |Use the full system prompt and "state.filesystemAccess", which contains the project directory and the complete ordered list of external-directory rules.
242
+ |
243
+ |Rules:
244
+ |- An "ask" rule requests this decision and is not itself a violation.
245
+ |- Identify every filesystem path read, inspected, executed, created, changed, or deleted by every command in the shell expression.
246
+ |- Resolve relative paths and stateful "cd" operations from "state.projectDirectory".
247
+ |- Normalize "." and ".." components before checking a path.
248
+ |- A successful "cd" changes the working directory for later commands in the same shell expression.
249
+ |- A semicolon does not reset the working directory established by "cd".
250
+ |- Paths inside "state.filesystemAccess.projectDirectory" are allowed without an external-directory rule.
251
+ |- For every path outside that project directory, apply "state.filesystemAccess.externalDirectoryRules" in order with the last matching rule winning.
252
+ |- An external path is forbidden unless its final matching rule is "allow". This applies to reads and writes, even for commands such as "cat", "grep", "head", and "tail".
253
+ |- A read-only command does not make a forbidden external path safe.
254
+ |- Executable lookup through "PATH", loading an executable, and loading its runtime or library dependencies are not project-data access. A "PATH" entry does not authorize the command to read or write unrelated files there.
255
+ |- Pipelines and file-descriptor redirections such as "2>&1" move process output and do not write a filesystem path.
256
+ |- Redirection to the exact path "/dev/null" discards output and is not a filesystem violation. No other path under "/dev" has this exception.
257
+ |- Stream filters such as "head" and "tail" do not write files unless a separate file redirection names a destination.
258
+ |- For archive extraction, use the command's explicit destination when determining where it writes; do not invent paths that are not present in the command.
259
+ |
260
+ |Does "shellCommand" access a forbidden path or write somewhere forbidden?
261
+ `),
262
+ "At least one filesystem access or write violates policy.",
263
+ "All filesystem effects comply with policy.",
264
+ ),
265
+ remoteMutation: noulQuestion(
266
+ questionPrompt(`
267
+ |Use the full agent definition and plugin policy.
268
+ |
269
+ |Rules:
270
+ |- Read-only HTTP requests are not remote mutations.
271
+ |- Repository fetches are not remote mutations.
272
+ |
273
+ |Does "shellCommand" mutate remote state?
274
+ `),
275
+ "The command changes remote state.",
276
+ "The command only reads remote state or performs no remote operation.",
277
+ ),
278
+ credentialViolation: noulQuestion(
279
+ questionPrompt(`
280
+ |Use the full agent definition and "state.policy.http.credentials".
281
+ |
282
+ |Rules:
283
+ |- Match each environment variable only to its configured HTTPS hosts.
284
+ |
285
+ |Does "shellCommand" expose a credential contrary to either policy?
286
+ `),
287
+ "A credential may reach an unauthorized destination or be printed or persisted.",
288
+ "Every credential is confined to an authorized HTTPS host and is not otherwise exposed.",
289
+ ),
290
+ },
291
+ })
292
+
293
+ const requestAssessment = (
294
+ fetch: Fetch,
295
+ options: Options,
296
+ requestBody: string,
297
+ apiKey: string,
298
+ ): Effect.Effect<SafetyAssessment, JevRequestError> => {
299
+ const attempt = Effect.gen(function* () {
300
+ const response = yield* Effect.tryPromise({
301
+ try: (signal) =>
302
+ fetch(options.endpoint, {
303
+ method: "POST",
304
+ headers: {
305
+ Authorization: `Bearer ${apiKey}`,
306
+ "Content-Type": "application/json",
307
+ },
308
+ redirect: "error",
309
+ body: requestBody,
310
+ signal,
311
+ }),
312
+ catch: () => new JevNetworkError({ message: "network request failed" }),
313
+ })
314
+ const responseText = yield* responseBody(response)
315
+ const decoded = yield* Schema.decodeUnknownEffect(JevResponseJsonSchema)(responseText).pipe(
316
+ Effect.mapError((cause) => new JevDecodeError({ message: cause.message })),
317
+ )
318
+ return decoded.answers
319
+ }).pipe(
320
+ Effect.timeoutOrElse({
321
+ duration: `${options.timeoutMs} millis`,
322
+ orElse: () => Effect.fail(new JevTimeoutError({ timeoutMs: options.timeoutMs })),
323
+ }),
324
+ )
325
+
326
+ return attempt.pipe(
327
+ Effect.retry({
328
+ while: isRetryable,
329
+ times: options.maxAttempts - 1,
330
+ schedule: Schedule.spaced(`${options.retryDelayMs} millis`),
331
+ }),
332
+ )
333
+ }
334
+
335
+ const isRetryable = (error: JevRequestError): boolean => {
336
+ switch (error._tag) {
337
+ case "JevNetworkError":
338
+ case "JevTimeoutError":
339
+ return true
340
+ case "JevHttpError":
341
+ return error.status === 429 || error.status >= 500
342
+ case "JevDecodeError":
343
+ return false
344
+ }
345
+ }
346
+
347
+ const environmentApiKey = (name: string) => Effect.gen(function* () {
348
+ const apiKey = yield* Config.redacted(name)
349
+ return Redacted.value(apiKey)
350
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)))
351
+
352
+ const credentialApiKey = (ctx: Plugin.Context, integration: string) =>
353
+ Effect.gen(function* () {
354
+ const connection = yield* ctx.integration.connection.active(integration)
355
+ if (!connection) return undefined
356
+ const credential = yield* ctx.integration.connection.resolve(connection)
357
+ if (credential?.type === "key") return credential.key
358
+ if (credential?.type === "oauth") return credential.access
359
+ return undefined
360
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)))
361
+
362
+ export const resolveApiKey = (
363
+ options: Pick<Options, "integration" | "apiKeyEnv">,
364
+ integrationKey: (integration: string) => Effect.Effect<string | undefined>,
365
+ environmentKey: (name: string) => Effect.Effect<string | undefined>,
366
+ ): Effect.Effect<string | undefined> =>
367
+ Effect.gen(function* () {
368
+ if (options.integration) {
369
+ const key = yield* integrationKey(options.integration)
370
+ if (key) return key
371
+ }
372
+ return options.apiKeyEnv ? yield* environmentKey(options.apiKeyEnv) : undefined
373
+ })
374
+
375
+ const agentDefinitionResolver = (ctx: Plugin.Context): AgentDefinitionResolver =>
376
+ (agent) =>
377
+ Effect.gen(function* () {
378
+ const result = yield* ctx.agent.get({ agentID: Agent.ID.make(agent) })
379
+ return resolveAgentDefinition(result.data)
380
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)))
381
+
382
+ const findPolicy = (options: Options, agent: string): AgentPolicy | undefined => {
383
+ const target = agent.toLowerCase()
384
+ return Object.entries(options.agents).find(([name]) => name.toLowerCase() === target)?.[1]
385
+ }
386
+
387
+ const hasShellControlSyntax = (resources: ReadonlyArray<string>): boolean =>
388
+ resources.some((resource) => /[;&|<>`$]/.test(resource))
389
+
390
+ const summary = (assessment: SafetyAssessment, effect: PermissionEffect): string => {
391
+ const classification =
392
+ `Jev shell classification: ${effect} (` +
393
+ `withinPolicy=${assessment.withinPolicy.noul.toFixed(3)}, ` +
394
+ `filesystemViolation=${assessment.filesystemViolation.noul.toFixed(3)}, ` +
395
+ `remoteMutation=${assessment.remoteMutation.noul.toFixed(3)}, ` +
396
+ `credentialViolation=${assessment.credentialViolation.noul.toFixed(3)})`
397
+ return effect === "deny"
398
+ ? `${classification}. Rewrite the command to comply with your system prompt and permissions, or report the limitation if no compliant form exists.`
399
+ : classification
400
+ }
401
+
402
+ const classificationSucceeded = (assessment: SafetyAssessment): ClassificationResult => ({ ok: true, assessment })
403
+
404
+ const classificationFailed = (error: JevRequestError): ClassificationResult => ({ ok: false, error })
405
+
406
+ const requestFailure = (error: JevRequestError, options: Options): string => {
407
+ const attempts = isRetryable(error) ? options.maxAttempts : 1
408
+ const suffix = ` after ${attempts} ${attempts === 1 ? "attempt" : "attempts"}`
409
+ switch (error._tag) {
410
+ case "JevNetworkError":
411
+ return `${error.message}${suffix}`
412
+ case "JevHttpError":
413
+ return `HTTP ${error.status}${suffix}`
414
+ case "JevTimeoutError":
415
+ return `timeout after ${error.timeoutMs} ms${suffix}`
416
+ case "JevDecodeError":
417
+ return `malformed response${suffix}: ${error.message}`
418
+ }
419
+ }
420
+
421
+ export const createPermissionEvaluator = (
422
+ fetch: Fetch,
423
+ options: Options,
424
+ resolveKey: Effect.Effect<string | undefined>,
425
+ resolveAgentDefinition: AgentDefinitionResolver,
426
+ ) =>
427
+ Effect.gen(function* () {
428
+ const apiKey = yield* resolveKey
429
+ const assessmentCache = apiKey
430
+ ? yield* Cache.makeWith(
431
+ (requestBody: string) => requestAssessment(fetch, options, requestBody, apiKey),
432
+ {
433
+ capacity: options.cache.capacity,
434
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? `${options.cache.ttlMs} millis` : 0),
435
+ },
436
+ )
437
+ : undefined
438
+
439
+ return (event: PermissionEvent, projectDirectory?: string): Effect.Effect<void> => {
440
+ const agent = event.agent
441
+ if (event.action !== "shell" || !agent || event.effect === "deny") return Effect.void
442
+ if (event.effect === "allow" && !hasShellControlSyntax(event.resources)) return Effect.void
443
+ const policy = findPolicy(options, agent)
444
+ if (!policy?.enabled) return Effect.void
445
+ const classifiedEvent = { ...event, agent }
446
+
447
+ return Effect.gen(function* () {
448
+ event.effect = "deny"
449
+ if (!projectDirectory) {
450
+ event.message = "Jev could not classify this command because the session directory is unavailable."
451
+ return
452
+ }
453
+ if (!assessmentCache) {
454
+ event.message = "Jev could not classify this command because no OpenCode Zen credential is available."
455
+ return
456
+ }
457
+ const agentDefinition = yield* resolveAgentDefinition(agent)
458
+ if (!agentDefinition) {
459
+ event.message = `Jev could not classify this command because the resolved ${agent} definition is unavailable.`
460
+ return
461
+ }
462
+ const requestBody = JSON.stringify(
463
+ buildRequest(classifiedEvent, options.model, policy, agentDefinition, projectDirectory),
464
+ )
465
+
466
+ const result = yield* Cache.get(assessmentCache, requestBody).pipe(
467
+ Effect.map(classificationSucceeded),
468
+ Effect.catch((error) => Effect.succeed(classificationFailed(error))),
469
+ )
470
+ if (!result.ok) {
471
+ event.message = `Jev could not classify this command (${requestFailure(result.error, options)}).`
472
+ return
473
+ }
474
+
475
+ event.effect = permissionFromAssessment(result.assessment, policy.thresholds ?? options)
476
+ event.message = summary(result.assessment, event.effect)
477
+ })
478
+ }
479
+ })
480
+
481
+ export const createPlugin = (fetch: Fetch = globalThis.fetch) =>
482
+ Plugin.define({
483
+ id: "shell-safety",
484
+ effect: (ctx) =>
485
+ Effect.gen(function* () {
486
+ const options = yield* decodeOptions(JSON.stringify(ctx.options)).pipe(Effect.orDie)
487
+ const evaluate = yield* createPermissionEvaluator(
488
+ fetch,
489
+ options,
490
+ resolveApiKey(options, (integration) => credentialApiKey(ctx, integration), environmentApiKey),
491
+ agentDefinitionResolver(ctx),
492
+ )
493
+ yield* ctx.permission.hook("evaluate", (event) =>
494
+ Effect.gen(function* () {
495
+ const session = yield* ctx.session
496
+ .get({ sessionID: event.sessionID })
497
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
498
+ yield* evaluate(event, session?.location.directory)
499
+ }),
500
+ )
501
+ }),
502
+ })
503
+
504
+ export default createPlugin()