opencode-shell-safety 0.2.3 → 0.4.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 CHANGED
@@ -78,6 +78,7 @@ Get an API key from [TypeSafe AI](https://docs.typesafe.ai/introduction/quicksta
78
78
  | `cache.capacity` | Maximum number of cached classification results. |
79
79
  | `cache.ttlMs` | Successful classification cache lifetime in milliseconds. |
80
80
  | `agents.<name>.enabled` | Enables classification for an agent. |
81
+ | `agents.<name>.fallback` | Optional. Effect applied when classification rejects the command or the request fails: `ask` or `deny`. Defaults to `deny`. |
81
82
  | `agents.<name>.thresholds` | Optional per-agent probability thresholds. |
82
83
  | `agents.<name>.http` | Optional. Omission permits no HTTP methods or credential hosts. |
83
84
  | `agents.<name>.http.methods` | HTTP methods the policy permits. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-shell-safety",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin that classifies shell permission requests against agent policy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ const ThresholdsSchema = Schema.Struct({
33
33
  violationProbability: Probability,
34
34
  })
35
35
 
36
+ const FallbackEffectSchema = Schema.Literals(["ask", "deny"])
37
+
36
38
  const HttpPolicySchema = Schema.Struct({
37
39
  methods: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
38
40
  credentials: Schema.Record(Schema.String, Schema.Array(Schema.String))
@@ -41,6 +43,7 @@ const HttpPolicySchema = Schema.Struct({
41
43
 
42
44
  const AgentPolicySchema = Schema.Struct({
43
45
  enabled: Schema.Boolean,
46
+ fallback: Schema.optionalKey(FallbackEffectSchema),
44
47
  thresholds: Schema.optionalKey(ThresholdsSchema),
45
48
  http: HttpPolicySchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({ methods: [], credentials: {} }))),
46
49
  })
@@ -75,6 +78,7 @@ export const OptionsSchema = Schema.Struct({
75
78
 
76
79
  export type Options = typeof OptionsSchema.Type
77
80
  export type AgentPolicy = typeof AgentPolicySchema.Type
81
+ export type FallbackEffect = typeof FallbackEffectSchema.Type
78
82
  export type Thresholds = typeof ThresholdsSchema.Type
79
83
 
80
84
  const NoulAnswerSchema = Schema.Struct({
@@ -161,10 +165,12 @@ export const resolveAgentDefinition = (agent: Agent.Info): ResolvedAgentDefiniti
161
165
  .map((rule) => ({ resource: rule.resource, effect: rule.effect })),
162
166
  })
163
167
 
168
+ export type ClassificationVerdict = "allow" | "deny"
169
+
164
170
  export const permissionFromAssessment = (
165
171
  assessment: SafetyAssessment,
166
172
  thresholds: Thresholds,
167
- ): PermissionEffect => {
173
+ ): ClassificationVerdict => {
168
174
  const hasViolation =
169
175
  assessment.filesystemViolation.noul >= thresholds.violationProbability ||
170
176
  assessment.remoteMutation.noul >= thresholds.violationProbability ||
@@ -172,6 +178,8 @@ export const permissionFromAssessment = (
172
178
  return assessment.withinPolicy.noul >= thresholds.allowProbability && !hasViolation ? "allow" : "deny"
173
179
  }
174
180
 
181
+ export const fallbackEffect = (fallback: FallbackEffect | undefined): PermissionEffect => fallback ?? "deny"
182
+
175
183
  const noulQuestion = (instructions: string, yes: string, no: string): NoulQuestion => ({
176
184
  type: "noul",
177
185
  instructions,
@@ -221,7 +229,6 @@ export const buildRequest = (
221
229
  |
222
230
  |Rules:
223
231
  |- Decide whether "shellCommand" complies with those instructions and with "state.policy".
224
- |- OpenCode invokes this classifier only after permission resolution returns "ask".
225
232
  |- An "ask" rule means this classifier must decide from the command's actual effects; it is not a violation.
226
233
  |- Shell deny rules remain final.
227
234
  |- Apply ordered permission rules with the last matching rule winning.
@@ -387,16 +394,17 @@ const findPolicy = (options: Options, agent: string): AgentPolicy | undefined =>
387
394
  const hasShellControlSyntax = (resources: ReadonlyArray<string>): boolean =>
388
395
  resources.some((resource) => /[;&|<>`$]/.test(resource))
389
396
 
390
- const summary = (assessment: SafetyAssessment, effect: PermissionEffect): string => {
397
+ const summary = (assessment: SafetyAssessment, verdict: ClassificationVerdict, effect: PermissionEffect): string => {
391
398
  const classification =
392
- `Jev shell classification: ${effect} (` +
399
+ `Jev shell classification: ${verdict} (` +
393
400
  `withinPolicy=${assessment.withinPolicy.noul.toFixed(3)}, ` +
394
401
  `filesystemViolation=${assessment.filesystemViolation.noul.toFixed(3)}, ` +
395
402
  `remoteMutation=${assessment.remoteMutation.noul.toFixed(3)}, ` +
396
403
  `credentialViolation=${assessment.credentialViolation.noul.toFixed(3)})`
404
+ if (verdict === "allow") return classification
397
405
  return effect === "deny"
398
406
  ? `${classification}. Rewrite the command to comply with your system prompt and permissions, or report the limitation if no compliant form exists.`
399
- : classification
407
+ : `${classification}. Jev rejected this command.`
400
408
  }
401
409
 
402
410
  const classificationSucceeded = (assessment: SafetyAssessment): ClassificationResult => ({ ok: true, assessment })
@@ -443,19 +451,22 @@ export const createPermissionEvaluator = (
443
451
  const policy = findPolicy(options, agent)
444
452
  if (!policy?.enabled) return Effect.void
445
453
  const classifiedEvent = { ...event, agent }
454
+ const fallback = fallbackEffect(policy.fallback)
446
455
 
447
456
  return Effect.gen(function* () {
448
- event.effect = "deny"
449
457
  if (!projectDirectory) {
458
+ event.effect = fallback
450
459
  event.message = "Jev could not classify this command because the session directory is unavailable."
451
460
  return
452
461
  }
453
462
  if (!assessmentCache) {
463
+ event.effect = fallback
454
464
  event.message = "Jev could not classify this command because no OpenCode Zen credential is available."
455
465
  return
456
466
  }
457
467
  const agentDefinition = yield* resolveAgentDefinition(agent)
458
468
  if (!agentDefinition) {
469
+ event.effect = fallback
459
470
  event.message = `Jev could not classify this command because the resolved ${agent} definition is unavailable.`
460
471
  return
461
472
  }
@@ -468,12 +479,14 @@ export const createPermissionEvaluator = (
468
479
  Effect.catch((error) => Effect.succeed(classificationFailed(error))),
469
480
  )
470
481
  if (!result.ok) {
482
+ event.effect = fallback
471
483
  event.message = `Jev could not classify this command (${requestFailure(result.error, options)}).`
472
484
  return
473
485
  }
474
486
 
475
- event.effect = permissionFromAssessment(result.assessment, policy.thresholds ?? options)
476
- event.message = summary(result.assessment, event.effect)
487
+ const verdict = permissionFromAssessment(result.assessment, policy.thresholds ?? options)
488
+ event.effect = verdict === "allow" ? "allow" : fallback
489
+ event.message = summary(result.assessment, verdict, event.effect)
477
490
  })
478
491
  }
479
492
  })