@jevvy/permissions 0.1.0 → 0.2.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 +25 -17
- package/dist/calibration-cli.d.ts +4 -2
- package/dist/calibration-cli.js +103 -103
- package/dist/calibration.d.ts +3 -3
- package/dist/config.d.ts +8 -20
- package/dist/config.js +24 -29
- package/dist/core.d.ts +2 -2
- package/dist/core.js +237 -10864
- package/dist/engine.d.ts +13 -5
- package/dist/engine.js +99 -52
- package/dist/opencode/credentials.d.ts +6 -16
- package/dist/opencode/credentials.js +16 -31
- package/dist/opencode/evaluate.d.ts +5 -2
- package/dist/opencode/evaluate.js +10 -5
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.js +51 -60
- package/dist/providers.d.ts +15 -0
- package/dist/providers.js +43 -0
- package/dist/questions.d.ts +33 -7
- package/dist/questions.js +16 -0
- package/package.json +2 -2
package/dist/engine.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { NoulAnswer } from "./core.ts";
|
|
3
|
+
import type { JevClient, JevProviderFailure } from "./core.ts";
|
|
4
|
+
import { ApprovalQuestions } from "./questions.ts";
|
|
3
5
|
export type PermissionEffect = "allow" | "ask";
|
|
4
6
|
export interface ResourceJudgment {
|
|
5
7
|
readonly resource: string;
|
|
@@ -14,19 +16,25 @@ export type PermissionReview = {
|
|
|
14
16
|
readonly effect: "ask";
|
|
15
17
|
readonly reason: "judged" | "unavailable" | "empty";
|
|
16
18
|
readonly judgments: readonly ResourceJudgment[];
|
|
19
|
+
readonly failure?: JevProviderFailure;
|
|
17
20
|
};
|
|
18
21
|
export interface PermissionRequest {
|
|
19
22
|
readonly action: string;
|
|
20
23
|
readonly resources: readonly string[];
|
|
21
24
|
}
|
|
22
25
|
export interface PermissionReviewer {
|
|
23
|
-
readonly review: (request: PermissionRequest
|
|
24
|
-
readonly dispose: () => Promise<void>;
|
|
26
|
+
readonly review: (request: PermissionRequest) => Effect.Effect<PermissionReview>;
|
|
25
27
|
}
|
|
26
28
|
export interface PermissionReviewerOptions {
|
|
27
29
|
readonly questions?: ApprovalQuestions;
|
|
28
30
|
readonly timeoutMs?: number;
|
|
29
31
|
readonly cacheCapacity?: number;
|
|
30
32
|
}
|
|
33
|
+
declare const ReviewerConfigurationError_base: Schema.Class<ReviewerConfigurationError, Schema.TaggedStruct<"ReviewerConfigurationError", {
|
|
34
|
+
readonly message: Schema.String;
|
|
35
|
+
}>, import("effect/Cause").YieldableError>;
|
|
36
|
+
export declare class ReviewerConfigurationError extends ReviewerConfigurationError_base {
|
|
37
|
+
}
|
|
31
38
|
export declare const permissionEffectOf: (answers: Readonly<Record<string, NoulAnswer>>, questions: ApprovalQuestions) => PermissionEffect;
|
|
32
|
-
export declare const createPermissionReviewer: (client: JevClient, options?: PermissionReviewerOptions) =>
|
|
39
|
+
export declare const createPermissionReviewer: (client: JevClient, options?: PermissionReviewerOptions | undefined) => Effect.Effect<PermissionReviewer, ReviewerConfigurationError, never>;
|
|
40
|
+
export {};
|
package/dist/engine.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
import { Cache, Duration, Effect, Exit } from "effect";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Cache, Clock, Duration, Effect, Exit, Ref, Schema } from "effect";
|
|
2
|
+
import { isJevProviderError, JevProviderFailure as JevProviderFailureSchema, NoulAnswer, providerFailureOf, } from "./core.js";
|
|
3
|
+
import { ApprovalQuestions, defaultApprovalQuestions, toNoulQuestions } from "./questions.js";
|
|
4
|
+
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
5
|
+
const EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
|
|
6
|
+
const cooldownMs = (failure) => {
|
|
7
|
+
if (failure.retryAfterMs !== undefined)
|
|
8
|
+
return failure.retryAfterMs;
|
|
9
|
+
if (failure.kind === "rate-limited")
|
|
10
|
+
return RATE_LIMIT_COOLDOWN_MS;
|
|
11
|
+
if (failure.kind === "credits-exhausted" || failure.kind === "quota-exhausted") {
|
|
12
|
+
return EXHAUSTED_COOLDOWN_MS;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
class JudgmentUnavailable extends Schema.TaggedError()("JudgmentUnavailable", {
|
|
17
|
+
message: Schema.String,
|
|
18
|
+
failure: Schema.optional(JevProviderFailureSchema),
|
|
19
|
+
}) {
|
|
20
|
+
}
|
|
21
|
+
export class ReviewerConfigurationError extends Schema.TaggedError()("ReviewerConfigurationError", { message: Schema.String }) {
|
|
5
22
|
}
|
|
6
23
|
const answerPasses = (answer, question) => question.threshold.direction === "atMost"
|
|
7
24
|
? answer <= question.threshold.value
|
|
8
25
|
: answer >= question.threshold.value;
|
|
9
|
-
const validAnswer =
|
|
26
|
+
const validAnswer = Schema.is(NoulAnswer);
|
|
10
27
|
export const permissionEffectOf = (answers, questions) => {
|
|
11
28
|
let effect = "allow";
|
|
12
29
|
for (const [key, question] of Object.entries(questions)) {
|
|
13
30
|
const answer = answers[key];
|
|
14
31
|
if (!validAnswer(answer))
|
|
15
|
-
throw new JudgmentUnavailable(`missing valid answer for ${key}`);
|
|
32
|
+
throw new JudgmentUnavailable({ message: `missing valid answer for ${key}` });
|
|
16
33
|
if (!answerPasses(answer.noul, question))
|
|
17
34
|
effect = "ask";
|
|
18
35
|
}
|
|
@@ -20,7 +37,7 @@ export const permissionEffectOf = (answers, questions) => {
|
|
|
20
37
|
};
|
|
21
38
|
const judgeResult = (resource, result, questions) => {
|
|
22
39
|
if (result.model.trim().length === 0)
|
|
23
|
-
throw new JudgmentUnavailable("missing model identifier");
|
|
40
|
+
throw new JudgmentUnavailable({ message: "missing model identifier" });
|
|
24
41
|
return {
|
|
25
42
|
resource,
|
|
26
43
|
effect: permissionEffectOf(result.answers, questions),
|
|
@@ -28,70 +45,100 @@ const judgeResult = (resource, result, questions) => {
|
|
|
28
45
|
answers: result.answers,
|
|
29
46
|
};
|
|
30
47
|
};
|
|
31
|
-
const validateQuestions = (questions) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (question.instructions.trim().length === 0)
|
|
39
|
-
throw new Error(`approval question ${key} has no instructions`);
|
|
40
|
-
if (!Number.isFinite(question.threshold.value) || question.threshold.value < 0 || question.threshold.value > 1) {
|
|
41
|
-
throw new Error(`approval question ${key} has an invalid threshold`);
|
|
42
|
-
}
|
|
48
|
+
const validateQuestions = (questions) => Schema.decodeUnknownEffect(ApprovalQuestions, { onExcessProperty: "error" })(questions).pipe(Effect.asVoid, Effect.mapError(() => new ReviewerConfigurationError({ message: "invalid approval questions" })));
|
|
49
|
+
const cacheKey = (action, resource) => `${action.length}:${action}${resource}`;
|
|
50
|
+
const unavailable = (error) => {
|
|
51
|
+
if (error instanceof JudgmentUnavailable)
|
|
52
|
+
return error;
|
|
53
|
+
if (isJevProviderError(error)) {
|
|
54
|
+
return new JudgmentUnavailable({ message: error.message, failure: providerFailureOf(error) });
|
|
43
55
|
}
|
|
56
|
+
return new JudgmentUnavailable({ message: String(error) });
|
|
44
57
|
};
|
|
45
|
-
const
|
|
46
|
-
const abortReason = (signal) => signal.reason instanceof Error
|
|
47
|
-
? signal.reason
|
|
48
|
-
: new DOMException("The operation was aborted", "AbortError");
|
|
49
|
-
const isAborted = (signal) => signal?.aborted === true;
|
|
50
|
-
export const createPermissionReviewer = async (client, options = {}) => {
|
|
58
|
+
export const createPermissionReviewer = Effect.fn("PermissionReviewer.create")(function* (client, options = {}) {
|
|
51
59
|
const questions = options.questions ?? defaultApprovalQuestions;
|
|
52
60
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
53
61
|
const capacity = options.cacheCapacity ?? 512;
|
|
54
|
-
validateQuestions(questions);
|
|
55
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
yield* validateQuestions(questions);
|
|
63
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
64
|
+
return yield* new ReviewerConfigurationError({ message: "timeoutMs must be positive" });
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
67
|
+
return yield* new ReviewerConfigurationError({ message: "cacheCapacity must be a positive integer" });
|
|
68
|
+
}
|
|
59
69
|
const noulQuestions = toNoulQuestions(questions);
|
|
60
|
-
const
|
|
70
|
+
const providerCooldown = yield* Ref.make(undefined);
|
|
71
|
+
const activeProviderFailure = Effect.fn("PermissionReviewer.activeProviderFailure")(function* () {
|
|
72
|
+
const now = yield* Clock.currentTimeMillis;
|
|
73
|
+
return yield* Ref.modify(providerCooldown, (current) => {
|
|
74
|
+
if (current === undefined || current.untilMs <= now)
|
|
75
|
+
return [undefined, undefined];
|
|
76
|
+
return [current.failure, current];
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
const recordProviderCooldown = Effect.fn("PermissionReviewer.recordProviderCooldown")(function* (failure) {
|
|
80
|
+
const duration = cooldownMs(failure);
|
|
81
|
+
if (duration === undefined)
|
|
82
|
+
return;
|
|
83
|
+
const now = yield* Clock.currentTimeMillis;
|
|
84
|
+
const next = { failure, untilMs: now + duration };
|
|
85
|
+
yield* Ref.update(providerCooldown, (current) => current === undefined || current.untilMs < next.untilMs ? next : current);
|
|
86
|
+
});
|
|
87
|
+
const judgeResource = Effect.fn("PermissionReviewer.judgeResource")(function* (key) {
|
|
61
88
|
const separator = key.indexOf(":");
|
|
62
89
|
const actionLength = Number(key.slice(0, separator));
|
|
63
90
|
const payload = key.slice(separator + 1);
|
|
64
91
|
const action = payload.slice(0, actionLength);
|
|
65
92
|
const resource = payload.slice(actionLength);
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
const disabled = yield* activeProviderFailure();
|
|
94
|
+
if (disabled !== undefined) {
|
|
95
|
+
return yield* new JudgmentUnavailable({ message: disabled.message, failure: disabled });
|
|
96
|
+
}
|
|
97
|
+
return yield* client.evaluate({ state: { action, resource }, questions: noulQuestions }).pipe(Effect.timeout(timeoutMs), Effect.flatMap((result) => Effect.try({
|
|
98
|
+
try: () => judgeResult(resource, result, questions),
|
|
99
|
+
catch: (cause) => unavailable(cause instanceof Error ? cause : new Error(String(cause))),
|
|
100
|
+
})), Effect.mapError(unavailable), Effect.tapError((error) => error.failure === undefined
|
|
101
|
+
? Effect.succeed(undefined)
|
|
102
|
+
: recordProviderCooldown(error.failure)));
|
|
103
|
+
});
|
|
104
|
+
const cache = yield* Cache.makeWith(judgeResource, {
|
|
68
105
|
capacity,
|
|
69
106
|
timeToLive: (exit) => Exit.isSuccess(exit) ? Duration.infinity : Duration.zero,
|
|
70
|
-
})
|
|
107
|
+
});
|
|
71
108
|
return {
|
|
72
|
-
|
|
109
|
+
review: Effect.fn("PermissionReviewer.review")(function* (request) {
|
|
73
110
|
if (request.resources.length === 0)
|
|
74
111
|
return { effect: "ask", reason: "empty", judgments: [] };
|
|
112
|
+
const disabled = yield* activeProviderFailure();
|
|
113
|
+
if (disabled !== undefined) {
|
|
114
|
+
return { effect: "ask", reason: "unavailable", judgments: [], failure: disabled };
|
|
115
|
+
}
|
|
75
116
|
const judgments = [];
|
|
76
117
|
for (const resource of request.resources) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
return {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
118
|
+
const result = yield* Cache.get(cache, cacheKey(request.action, resource)).pipe(Effect.match({
|
|
119
|
+
onFailure: (error) => ({ kind: "failure", error }),
|
|
120
|
+
onSuccess: (judgment) => ({ kind: "success", judgment }),
|
|
121
|
+
}));
|
|
122
|
+
if (result.kind === "failure") {
|
|
123
|
+
if (result.error.failure !== undefined) {
|
|
124
|
+
return {
|
|
125
|
+
effect: "ask",
|
|
126
|
+
reason: "unavailable",
|
|
127
|
+
judgments,
|
|
128
|
+
failure: result.error.failure,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
effect: "ask",
|
|
133
|
+
reason: "unavailable",
|
|
134
|
+
judgments,
|
|
135
|
+
};
|
|
89
136
|
}
|
|
137
|
+
judgments.push(result.judgment);
|
|
138
|
+
if (result.judgment.effect === "ask")
|
|
139
|
+
return { effect: "ask", reason: "judged", judgments };
|
|
90
140
|
}
|
|
91
141
|
return { effect: "allow", judgments };
|
|
92
|
-
},
|
|
93
|
-
async dispose() {
|
|
94
|
-
await client.dispose?.();
|
|
95
|
-
},
|
|
142
|
+
}),
|
|
96
143
|
};
|
|
97
|
-
};
|
|
144
|
+
});
|
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { JevClient } from "../core.ts";
|
|
3
|
+
import type { ProviderApiKeys, SelectedProvider } from "../providers.ts";
|
|
2
4
|
export declare const OPENCODE_INTEGRATION = "opencode";
|
|
3
|
-
export type ProviderCredential = {
|
|
4
|
-
readonly kind: "zen";
|
|
5
|
-
readonly key: string;
|
|
6
|
-
readonly model: string;
|
|
7
|
-
readonly origin: "opencode" | "config";
|
|
8
|
-
} | {
|
|
9
|
-
readonly kind: "typesafe";
|
|
10
|
-
readonly key: string;
|
|
11
|
-
readonly origin: "config";
|
|
12
|
-
} | {
|
|
13
|
-
readonly kind: "unavailable";
|
|
14
|
-
};
|
|
15
5
|
export type StoredCredential = {
|
|
16
6
|
readonly type: "key";
|
|
17
7
|
readonly key: string;
|
|
@@ -21,8 +11,8 @@ export type StoredCredential = {
|
|
|
21
11
|
readonly access: string;
|
|
22
12
|
};
|
|
23
13
|
export interface CredentialPorts<C> {
|
|
24
|
-
readonly activeIntegration: (id: string) =>
|
|
25
|
-
readonly resolveIntegration: (connection: C) =>
|
|
14
|
+
readonly activeIntegration: (id: string) => Effect.Effect<C | undefined>;
|
|
15
|
+
readonly resolveIntegration: (connection: C) => Effect.Effect<StoredCredential | undefined>;
|
|
26
16
|
readonly readEnv: (name: string) => string | undefined;
|
|
27
17
|
}
|
|
28
18
|
export interface ConnectionLike {
|
|
@@ -30,4 +20,4 @@ export interface ConnectionLike {
|
|
|
30
20
|
readonly envName?: string;
|
|
31
21
|
}
|
|
32
22
|
export declare const credentialToken: (credential: StoredCredential | undefined) => string | undefined;
|
|
33
|
-
export declare const
|
|
23
|
+
export declare const selectOpenCodeProvider: <C>(ports: CredentialPorts<C>, preference: "auto" | "openrouter" | "typesafe" | "vercel" | "zen", describe: (connection: C) => ConnectionLike, apiKeys: ProviderApiKeys, createOpenCodeClient: (apiKey: string) => JevClient) => Effect.Effect<SelectedProvider | undefined, never, never>;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { createProvider, selectConfiguredProvider } from "../providers.js";
|
|
3
3
|
export const OPENCODE_INTEGRATION = "opencode";
|
|
4
4
|
export const credentialToken = (credential) => {
|
|
5
5
|
const token = credential?.type === "key" ? credential.key : credential?.access;
|
|
6
6
|
return token !== undefined && token.trim().length > 0 ? token : undefined;
|
|
7
7
|
};
|
|
8
|
-
const
|
|
9
|
-
const connection =
|
|
8
|
+
const resolveOpenCodeCredential = Effect.fn("Credentials.resolveOpenCodeCredential")(function* (ports, describe) {
|
|
9
|
+
const connection = yield* ports.activeIntegration(OPENCODE_INTEGRATION);
|
|
10
10
|
if (connection === undefined)
|
|
11
11
|
return undefined;
|
|
12
12
|
const like = describe(connection);
|
|
@@ -14,31 +14,16 @@ const integrationCredential = async (ports, id, describe) => {
|
|
|
14
14
|
const key = ports.readEnv(like.envName);
|
|
15
15
|
return key === undefined ? undefined : { type: "key", key };
|
|
16
16
|
}
|
|
17
|
-
const resolved =
|
|
17
|
+
const resolved = yield* ports.resolveIntegration(connection);
|
|
18
18
|
return credentialToken(resolved) === undefined ? undefined : resolved;
|
|
19
|
-
};
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
31
|
-
const typeSafeCandidate = (configuredKey) => {
|
|
32
|
-
if (configuredKey !== undefined) {
|
|
33
|
-
return { kind: "typesafe", key: Redacted.value(configuredKey), origin: "config" };
|
|
34
|
-
}
|
|
35
|
-
return { kind: "unavailable" };
|
|
36
|
-
};
|
|
37
|
-
export const resolveCredential = async (ports, preference, describe, apiKeys = {}) => {
|
|
38
|
-
if (preference === "zen")
|
|
39
|
-
return zenCandidate(ports, describe, apiKeys.zen);
|
|
40
|
-
if (preference === "typesafe")
|
|
41
|
-
return typeSafeCandidate(apiKeys.typesafe);
|
|
42
|
-
const zen = await zenCandidate(ports, describe, apiKeys.zen);
|
|
43
|
-
return zen.kind === "unavailable" ? typeSafeCandidate(apiKeys.typesafe) : zen;
|
|
44
|
-
};
|
|
19
|
+
});
|
|
20
|
+
export const selectOpenCodeProvider = Effect.fn("Credentials.selectOpenCodeProvider")(function* (ports, preference, describe, apiKeys, createOpenCodeClient) {
|
|
21
|
+
const configured = selectConfiguredProvider(preference, apiKeys);
|
|
22
|
+
if (preference !== "auto" && preference !== "zen")
|
|
23
|
+
return configured;
|
|
24
|
+
const credential = yield* resolveOpenCodeCredential(ports, describe);
|
|
25
|
+
const apiKey = credentialToken(credential);
|
|
26
|
+
return apiKey === undefined
|
|
27
|
+
? configured
|
|
28
|
+
: createProvider("zen", apiKey, createOpenCodeClient(apiKey));
|
|
29
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { PermissionEvaluation } from "@opencode/plugin/
|
|
1
|
+
import type { PermissionEvaluation } from "@opencode/plugin/effect/permission";
|
|
2
|
+
import { Effect } from "effect";
|
|
2
3
|
import type { PermissionReviewer } from "../engine.ts";
|
|
4
|
+
import type { JevProviderFailure } from "../core.ts";
|
|
3
5
|
export interface EvaluationEvent {
|
|
4
6
|
readonly sessionID: string;
|
|
5
7
|
readonly action: string;
|
|
@@ -13,6 +15,7 @@ export interface EvaluateOptions {
|
|
|
13
15
|
readonly effect: "allow" | "ask";
|
|
14
16
|
readonly reason: "judged" | "unavailable" | "empty";
|
|
15
17
|
readonly resources: number;
|
|
18
|
+
readonly failure?: JevProviderFailure;
|
|
16
19
|
}) => void;
|
|
17
20
|
}
|
|
18
|
-
export declare const createEvaluate: (reviewer: PermissionReviewer | undefined, options: EvaluateOptions) => ((event: EvaluationEvent) =>
|
|
21
|
+
export declare const createEvaluate: (reviewer: PermissionReviewer | undefined, options: EvaluateOptions) => ((event: EvaluationEvent) => Effect.Effect<void>);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Option, Schema } from "effect";
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
2
|
const CommandMetadata = Schema.Struct({ command: Schema.NonEmptyString });
|
|
3
3
|
const commandFrom = (metadata) => Schema.decodeUnknownOption(CommandMetadata)(metadata).pipe(Option.map(({ command }) => command.trim()), Option.filter((command) => command.length > 0), Option.getOrUndefined);
|
|
4
|
-
export const createEvaluate = (reviewer, options) =>
|
|
4
|
+
export const createEvaluate = (reviewer, options) => Effect.fn("OpenCode.evaluatePermission")(function* (event) {
|
|
5
5
|
if (event.effect !== "ask" || event.action !== "shell" || reviewer === undefined)
|
|
6
6
|
return;
|
|
7
7
|
const command = commandFrom(event.metadata);
|
|
@@ -12,9 +12,14 @@ export const createEvaluate = (reviewer, options) => async (event) => {
|
|
|
12
12
|
const resources = command === undefined
|
|
13
13
|
? event.resources
|
|
14
14
|
: [...new Set([...event.resources, command])];
|
|
15
|
-
const review =
|
|
15
|
+
const review = yield* reviewer.review({ action: event.action, resources });
|
|
16
16
|
const reason = review.effect === "ask" ? review.reason : "judged";
|
|
17
|
-
|
|
17
|
+
if (review.effect === "ask" && review.failure !== undefined) {
|
|
18
|
+
options.report?.({ effect: review.effect, reason, resources: resources.length, failure: review.failure });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
options.report?.({ effect: review.effect, reason, resources: resources.length });
|
|
22
|
+
}
|
|
18
23
|
if (review.effect === "allow")
|
|
19
24
|
event.effect = "allow";
|
|
20
|
-
};
|
|
25
|
+
});
|
package/dist/opencode/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { Plugin } from "@opencode/plugin";
|
|
2
|
-
declare const _default: Plugin.Plugin
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
declare const _default: Plugin.Plugin<import("effect/Scope").Scope>;
|
|
3
3
|
export default _default;
|
package/dist/opencode/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Plugin } from "@opencode/plugin";
|
|
1
|
+
import { createZenClient, JevProviderError } from "../core.js";
|
|
2
|
+
import { Plugin } from "@opencode/plugin/effect";
|
|
3
3
|
import { Effect } from "effect";
|
|
4
4
|
import { loadJevvyConfig } from "../config.js";
|
|
5
5
|
import { createPermissionReviewer } from "../engine.js";
|
|
6
6
|
import { createEvaluate } from "./evaluate.js";
|
|
7
|
-
import { credentialToken, OPENCODE_INTEGRATION,
|
|
7
|
+
import { credentialToken, OPENCODE_INTEGRATION, selectOpenCodeProvider, } from "./credentials.js";
|
|
8
8
|
const describeConnection = (connection) => connection.type === "env"
|
|
9
9
|
? { kind: "env", envName: connection.name }
|
|
10
10
|
: { kind: "credential" };
|
|
@@ -18,74 +18,65 @@ const storedCredentialFrom = (credential) => {
|
|
|
18
18
|
};
|
|
19
19
|
export default Plugin.define({
|
|
20
20
|
id: "jevvy.permissions",
|
|
21
|
-
|
|
22
|
-
const permissionConfig =
|
|
21
|
+
effect: Effect.fn("JevvyPlugin.setup")(function* (ctx) {
|
|
22
|
+
const permissionConfig = yield* loadJevvyConfig();
|
|
23
23
|
if (permissionConfig.kind === "invalid") {
|
|
24
|
-
|
|
24
|
+
yield* Effect.sync(() => {
|
|
25
|
+
console.warn("[jevvy] jevvy.jsonc failed validation, auto-approval is disabled", permissionConfig.message);
|
|
26
|
+
});
|
|
25
27
|
}
|
|
26
28
|
const ports = {
|
|
27
29
|
activeIntegration: (id) => ctx.integration.connection.active(id),
|
|
28
|
-
resolveIntegration:
|
|
29
|
-
try {
|
|
30
|
-
return storedCredentialFrom(await ctx.integration.connection.resolve(connection));
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
},
|
|
30
|
+
resolveIntegration: (connection) => ctx.integration.connection.resolve(connection).pipe(Effect.map(storedCredentialFrom), Effect.catch(() => Effect.succeed(undefined))),
|
|
36
31
|
readEnv: (name) => process.env[name],
|
|
37
32
|
};
|
|
38
33
|
const apiKeys = permissionConfig.kind === "invalid" ? {} : permissionConfig.apiKeys;
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
client = createZenClient(credential.key, credential.model);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
34
|
+
const preference = permissionConfig.kind === "invalid" ? "auto" : permissionConfig.provider;
|
|
35
|
+
const selected = permissionConfig.kind === "invalid"
|
|
36
|
+
? undefined
|
|
37
|
+
: yield* selectOpenCodeProvider(ports, preference, describeConnection, apiKeys, () => ({
|
|
38
|
+
evaluate: Effect.fn("JevvyPlugin.evaluateWithOpenCodeCredential")(function* (request) {
|
|
39
|
+
const connection = yield* ctx.integration.connection.active(OPENCODE_INTEGRATION);
|
|
40
|
+
const stored = connection === undefined
|
|
41
|
+
? undefined
|
|
42
|
+
: yield* ctx.integration.connection.resolve(connection).pipe(Effect.map(storedCredentialFrom), Effect.catch(() => Effect.succeed(undefined)));
|
|
43
|
+
const token = credentialToken(stored);
|
|
44
|
+
if (token === undefined) {
|
|
45
|
+
return yield* new JevProviderError({
|
|
46
|
+
provider: "zen",
|
|
47
|
+
kind: "authentication",
|
|
48
|
+
message: "OpenCode login is unavailable",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return yield* createZenClient(token).evaluate(request);
|
|
52
|
+
}),
|
|
53
|
+
}));
|
|
64
54
|
const questions = permissionConfig.kind === "custom" ? permissionConfig.questions : undefined;
|
|
65
|
-
const reviewer =
|
|
55
|
+
const reviewer = selected === undefined || permissionConfig.kind === "invalid"
|
|
66
56
|
? undefined
|
|
67
|
-
:
|
|
68
|
-
if (
|
|
69
|
-
|
|
57
|
+
: yield* createPermissionReviewer(selected.client, { questions }).pipe(Effect.orDie);
|
|
58
|
+
if (selected === undefined && permissionConfig.kind !== "invalid") {
|
|
59
|
+
yield* Effect.sync(() => {
|
|
60
|
+
console.warn("[jevvy] no provider credential, native permission prompts remain unchanged");
|
|
61
|
+
});
|
|
70
62
|
}
|
|
71
63
|
const evaluate = createEvaluate(reviewer, {
|
|
72
|
-
report: (entry) =>
|
|
64
|
+
report: (entry) => {
|
|
65
|
+
if (entry.failure !== undefined) {
|
|
66
|
+
console.warn("[jevvy] provider failure, native permission prompt remains unchanged", entry);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.info("[jevvy] permission review", entry);
|
|
70
|
+
},
|
|
73
71
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
model,
|
|
83
|
-
questions: permissionConfig.kind === "custom" ? "custom" : "calibrated-defaults",
|
|
84
|
-
autoApproval: reviewer === undefined ? "disabled" : "enabled",
|
|
72
|
+
yield* ctx.permission.hook("evaluate", evaluate);
|
|
73
|
+
yield* Effect.sync(() => {
|
|
74
|
+
console.info("[jevvy] loaded", {
|
|
75
|
+
provider: selected?.provider ?? "unavailable",
|
|
76
|
+
model: selected?.model,
|
|
77
|
+
questions: permissionConfig.kind === "custom" ? "custom" : "calibrated-defaults",
|
|
78
|
+
autoApproval: reviewer === undefined ? "disabled" : "enabled",
|
|
79
|
+
});
|
|
85
80
|
});
|
|
86
|
-
|
|
87
|
-
await permissions.dispose();
|
|
88
|
-
await reviewer?.dispose();
|
|
89
|
-
};
|
|
90
|
-
},
|
|
81
|
+
}),
|
|
91
82
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JevClient, JevProvider as JevProviderType } from "./core.ts";
|
|
2
|
+
import { Redacted, Schema } from "effect";
|
|
3
|
+
export declare const ProviderPreference: Schema.Literals<readonly ["auto", "zen", "typesafe", "openrouter", "vercel"]>;
|
|
4
|
+
export type ProviderPreference = Schema.Schema.Type<typeof ProviderPreference>;
|
|
5
|
+
export type ProviderApiKeys = {
|
|
6
|
+
readonly [Provider in JevProviderType]?: Redacted.Redacted<string>;
|
|
7
|
+
};
|
|
8
|
+
export interface SelectedProvider {
|
|
9
|
+
readonly provider: JevProviderType;
|
|
10
|
+
readonly model: string;
|
|
11
|
+
readonly client: JevClient;
|
|
12
|
+
readonly redact: (text: string) => string;
|
|
13
|
+
}
|
|
14
|
+
export declare const createProvider: (provider: JevProviderType, apiKey: string, client?: JevClient) => SelectedProvider;
|
|
15
|
+
export declare const selectConfiguredProvider: (preference: ProviderPreference, apiKeys: ProviderApiKeys) => SelectedProvider | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createOpenRouterClient, createTypeSafeClient, createVercelClient, createZenClient, DEFAULT_OPENROUTER_MODEL, DEFAULT_TYPESAFE_MODEL, DEFAULT_VERCEL_MODEL, DEFAULT_ZEN_MODEL, JevProvider, } from "./core.js";
|
|
2
|
+
import { Redacted, Schema } from "effect";
|
|
3
|
+
export const ProviderPreference = Schema.Literals(["auto", ...JevProvider.literals]);
|
|
4
|
+
const providerDefinitions = {
|
|
5
|
+
zen: {
|
|
6
|
+
automaticPriority: 0,
|
|
7
|
+
model: DEFAULT_ZEN_MODEL,
|
|
8
|
+
createClient: createZenClient,
|
|
9
|
+
},
|
|
10
|
+
typesafe: {
|
|
11
|
+
automaticPriority: 1,
|
|
12
|
+
model: DEFAULT_TYPESAFE_MODEL,
|
|
13
|
+
createClient: createTypeSafeClient,
|
|
14
|
+
},
|
|
15
|
+
openrouter: {
|
|
16
|
+
automaticPriority: 2,
|
|
17
|
+
model: DEFAULT_OPENROUTER_MODEL,
|
|
18
|
+
createClient: createOpenRouterClient,
|
|
19
|
+
},
|
|
20
|
+
vercel: {
|
|
21
|
+
automaticPriority: 3,
|
|
22
|
+
model: DEFAULT_VERCEL_MODEL,
|
|
23
|
+
createClient: createVercelClient,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const automaticProviders = [...JevProvider.literals].sort((left, right) => providerDefinitions[left].automaticPriority - providerDefinitions[right].automaticPriority);
|
|
27
|
+
export const createProvider = (provider, apiKey, client = providerDefinitions[provider].createClient(apiKey)) => ({
|
|
28
|
+
provider,
|
|
29
|
+
model: providerDefinitions[provider].model,
|
|
30
|
+
client,
|
|
31
|
+
redact: (text) => apiKey.length === 0 ? text : text.replaceAll(apiKey, "[redacted]"),
|
|
32
|
+
});
|
|
33
|
+
export const selectConfiguredProvider = (preference, apiKeys) => {
|
|
34
|
+
const candidates = preference === "auto"
|
|
35
|
+
? automaticProviders
|
|
36
|
+
: [preference];
|
|
37
|
+
for (const provider of candidates) {
|
|
38
|
+
const apiKey = apiKeys[provider];
|
|
39
|
+
if (apiKey !== undefined)
|
|
40
|
+
return createProvider(provider, Redacted.value(apiKey));
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
};
|
package/dist/questions.d.ts
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
readonly
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { NoulQuestion } from "./core.ts";
|
|
3
|
+
export declare const ApprovalThreshold: Schema.Struct<{
|
|
4
|
+
readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
|
|
5
|
+
readonly value: Schema.Finite;
|
|
6
|
+
}>;
|
|
7
|
+
export interface ApprovalThreshold extends Schema.Schema.Type<typeof ApprovalThreshold> {
|
|
5
8
|
}
|
|
6
|
-
export
|
|
7
|
-
readonly
|
|
9
|
+
export declare const ApprovalQuestion: Schema.Struct<{
|
|
10
|
+
readonly type: Schema.Literal<"noul">;
|
|
11
|
+
readonly instructions: Schema.NonEmptyString;
|
|
12
|
+
readonly criteria: Schema.optionalKey<Schema.Struct<{
|
|
13
|
+
readonly false: Schema.NonEmptyString;
|
|
14
|
+
readonly true: Schema.NonEmptyString;
|
|
15
|
+
}>>;
|
|
16
|
+
readonly threshold: Schema.Struct<{
|
|
17
|
+
readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
|
|
18
|
+
readonly value: Schema.Finite;
|
|
19
|
+
}>;
|
|
20
|
+
}>;
|
|
21
|
+
export interface ApprovalQuestion extends Schema.Schema.Type<typeof ApprovalQuestion> {
|
|
8
22
|
}
|
|
9
|
-
export
|
|
23
|
+
export declare const ApprovalQuestions: Schema.$Record<Schema.NonEmptyString, Schema.Struct<{
|
|
24
|
+
readonly type: Schema.Literal<"noul">;
|
|
25
|
+
readonly instructions: Schema.NonEmptyString;
|
|
26
|
+
readonly criteria: Schema.optionalKey<Schema.Struct<{
|
|
27
|
+
readonly false: Schema.NonEmptyString;
|
|
28
|
+
readonly true: Schema.NonEmptyString;
|
|
29
|
+
}>>;
|
|
30
|
+
readonly threshold: Schema.Struct<{
|
|
31
|
+
readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
|
|
32
|
+
readonly value: Schema.Finite;
|
|
33
|
+
}>;
|
|
34
|
+
}>>;
|
|
35
|
+
export type ApprovalQuestions = Schema.Schema.Type<typeof ApprovalQuestions>;
|
|
10
36
|
/**
|
|
11
37
|
* The shipped inquiry set is calibrated as one unit with its thresholds and
|
|
12
38
|
* pinned models. Rewording or overriding any entry creates a custom policy.
|