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.
- package/README.md +88 -0
- package/package.json +45 -0
- 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()
|