@jevvy/permissions 0.0.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.
@@ -0,0 +1,32 @@
1
+ import type { JevClient, NoulAnswer } from "./core.ts";
2
+ import type { ApprovalQuestions } from "./questions.ts";
3
+ export type PermissionEffect = "allow" | "ask";
4
+ export interface ResourceJudgment {
5
+ readonly resource: string;
6
+ readonly effect: PermissionEffect;
7
+ readonly model: string;
8
+ readonly answers: Readonly<Record<string, NoulAnswer>>;
9
+ }
10
+ export type PermissionReview = {
11
+ readonly effect: "allow";
12
+ readonly judgments: readonly ResourceJudgment[];
13
+ } | {
14
+ readonly effect: "ask";
15
+ readonly reason: "judged" | "unavailable" | "empty";
16
+ readonly judgments: readonly ResourceJudgment[];
17
+ };
18
+ export interface PermissionRequest {
19
+ readonly action: string;
20
+ readonly resources: readonly string[];
21
+ }
22
+ export interface PermissionReviewer {
23
+ readonly review: (request: PermissionRequest, signal?: AbortSignal) => Promise<PermissionReview>;
24
+ readonly dispose: () => Promise<void>;
25
+ }
26
+ export interface PermissionReviewerOptions {
27
+ readonly questions?: ApprovalQuestions;
28
+ readonly timeoutMs?: number;
29
+ readonly cacheCapacity?: number;
30
+ }
31
+ export declare const permissionEffectOf: (answers: Readonly<Record<string, NoulAnswer>>, questions: ApprovalQuestions) => PermissionEffect;
32
+ export declare const createPermissionReviewer: (client: JevClient, options?: PermissionReviewerOptions) => Promise<PermissionReviewer>;
package/dist/engine.js ADDED
@@ -0,0 +1,97 @@
1
+ import { Cache, Duration, Effect, Exit } from "effect";
2
+ import { defaultApprovalQuestions, toNoulQuestions } from "./questions.js";
3
+ class JudgmentUnavailable extends Error {
4
+ name = "JudgmentUnavailable";
5
+ }
6
+ const answerPasses = (answer, question) => question.threshold.direction === "atMost"
7
+ ? answer <= question.threshold.value
8
+ : answer >= question.threshold.value;
9
+ const validAnswer = (answer) => answer?.type === "noul" && Number.isFinite(answer.noul) && answer.noul >= 0 && answer.noul <= 1;
10
+ export const permissionEffectOf = (answers, questions) => {
11
+ let effect = "allow";
12
+ for (const [key, question] of Object.entries(questions)) {
13
+ const answer = answers[key];
14
+ if (!validAnswer(answer))
15
+ throw new JudgmentUnavailable(`missing valid answer for ${key}`);
16
+ if (!answerPasses(answer.noul, question))
17
+ effect = "ask";
18
+ }
19
+ return effect;
20
+ };
21
+ const judgeResult = (resource, result, questions) => {
22
+ if (result.model.trim().length === 0)
23
+ throw new JudgmentUnavailable("missing model identifier");
24
+ return {
25
+ resource,
26
+ effect: permissionEffectOf(result.answers, questions),
27
+ model: result.model,
28
+ answers: result.answers,
29
+ };
30
+ };
31
+ const validateQuestions = (questions) => {
32
+ const entries = Object.entries(questions);
33
+ if (entries.length === 0)
34
+ throw new Error("at least one approval question is required");
35
+ for (const [key, question] of entries) {
36
+ if (key.length === 0)
37
+ throw new Error("approval question keys cannot be empty");
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
+ }
43
+ }
44
+ };
45
+ const cacheKey = (action, resource) => `${action.length}:${action}${resource}`;
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 = {}) => {
51
+ const questions = options.questions ?? defaultApprovalQuestions;
52
+ const timeoutMs = options.timeoutMs ?? 5_000;
53
+ const capacity = options.cacheCapacity ?? 512;
54
+ validateQuestions(questions);
55
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
56
+ throw new Error("timeoutMs must be positive");
57
+ if (!Number.isInteger(capacity) || capacity <= 0)
58
+ throw new Error("cacheCapacity must be a positive integer");
59
+ const noulQuestions = toNoulQuestions(questions);
60
+ const cache = await Effect.runPromise(Cache.makeWith((key) => {
61
+ const separator = key.indexOf(":");
62
+ const actionLength = Number(key.slice(0, separator));
63
+ const payload = key.slice(separator + 1);
64
+ const action = payload.slice(0, actionLength);
65
+ const resource = payload.slice(actionLength);
66
+ return Effect.tryPromise((signal) => client.evaluate({ state: { action, resource }, questions: noulQuestions }, signal)).pipe(Effect.timeout(timeoutMs), Effect.map((result) => judgeResult(resource, result, questions)), Effect.mapError((error) => new JudgmentUnavailable(String(error))));
67
+ }, {
68
+ capacity,
69
+ timeToLive: (exit) => Exit.isSuccess(exit) ? Duration.infinity : Duration.zero,
70
+ }));
71
+ return {
72
+ async review(request, signal) {
73
+ if (request.resources.length === 0)
74
+ return { effect: "ask", reason: "empty", judgments: [] };
75
+ const judgments = [];
76
+ for (const resource of request.resources) {
77
+ if (isAborted(signal))
78
+ throw abortReason(signal);
79
+ try {
80
+ const judgment = await Effect.runPromise(Cache.get(cache, cacheKey(request.action, resource)), signal === undefined ? undefined : { signal });
81
+ judgments.push(judgment);
82
+ if (judgment.effect === "ask")
83
+ return { effect: "ask", reason: "judged", judgments };
84
+ }
85
+ catch {
86
+ if (isAborted(signal))
87
+ throw abortReason(signal);
88
+ return { effect: "ask", reason: "unavailable", judgments };
89
+ }
90
+ }
91
+ return { effect: "allow", judgments };
92
+ },
93
+ async dispose() {
94
+ await client.dispose?.();
95
+ },
96
+ };
97
+ };
@@ -0,0 +1 @@
1
+ export { default } from "./opencode/index.ts";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./opencode/index.js";
@@ -0,0 +1,33 @@
1
+ import type { ProviderApiKeys, ProviderPreference } from "../config.ts";
2
+ 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
+ export type StoredCredential = {
16
+ readonly type: "key";
17
+ readonly key: string;
18
+ readonly configuration?: Readonly<Record<string, string | number | boolean | readonly string[]>>;
19
+ } | {
20
+ readonly type: "oauth";
21
+ readonly access: string;
22
+ };
23
+ export interface CredentialPorts<C> {
24
+ readonly activeIntegration: (id: string) => Promise<C | undefined>;
25
+ readonly resolveIntegration: (connection: C) => Promise<StoredCredential | undefined>;
26
+ readonly readEnv: (name: string) => string | undefined;
27
+ }
28
+ export interface ConnectionLike {
29
+ readonly kind: "credential" | "env";
30
+ readonly envName?: string;
31
+ }
32
+ export declare const credentialToken: (credential: StoredCredential | undefined) => string | undefined;
33
+ export declare const resolveCredential: <C>(ports: CredentialPorts<C>, preference: ProviderPreference, describe: (connection: C) => ConnectionLike, apiKeys?: ProviderApiKeys) => Promise<ProviderCredential>;
@@ -0,0 +1,44 @@
1
+ import { DEFAULT_ZEN_MODEL } from "../core.js";
2
+ import { Redacted } from "effect";
3
+ export const OPENCODE_INTEGRATION = "opencode";
4
+ export const credentialToken = (credential) => {
5
+ const token = credential?.type === "key" ? credential.key : credential?.access;
6
+ return token !== undefined && token.trim().length > 0 ? token : undefined;
7
+ };
8
+ const integrationCredential = async (ports, id, describe) => {
9
+ const connection = await ports.activeIntegration(id);
10
+ if (connection === undefined)
11
+ return undefined;
12
+ const like = describe(connection);
13
+ if (like.kind === "env" && like.envName !== undefined) {
14
+ const key = ports.readEnv(like.envName);
15
+ return key === undefined ? undefined : { type: "key", key };
16
+ }
17
+ const resolved = await ports.resolveIntegration(connection);
18
+ return credentialToken(resolved) === undefined ? undefined : resolved;
19
+ };
20
+ const zenCandidate = async (ports, describe, configuredKey) => {
21
+ const opencode = await integrationCredential(ports, OPENCODE_INTEGRATION, describe);
22
+ const opencodeToken = credentialToken(opencode);
23
+ if (opencodeToken !== undefined) {
24
+ return { kind: "zen", key: opencodeToken, model: DEFAULT_ZEN_MODEL, origin: "opencode" };
25
+ }
26
+ if (configuredKey !== undefined) {
27
+ return { kind: "zen", key: Redacted.value(configuredKey), model: DEFAULT_ZEN_MODEL, origin: "config" };
28
+ }
29
+ return { kind: "unavailable" };
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
+ };
@@ -0,0 +1,18 @@
1
+ import type { PermissionEvaluation } from "@opencode/plugin/promise/permission";
2
+ import type { PermissionReviewer } from "../engine.ts";
3
+ export interface EvaluationEvent {
4
+ readonly sessionID: string;
5
+ readonly action: string;
6
+ readonly resources: PermissionEvaluation["resources"];
7
+ readonly metadata?: PermissionEvaluation["metadata"];
8
+ effect: PermissionEvaluation["effect"];
9
+ message?: string;
10
+ }
11
+ export interface EvaluateOptions {
12
+ readonly report?: (entry: {
13
+ readonly effect: "allow" | "ask";
14
+ readonly reason: "judged" | "unavailable" | "empty";
15
+ readonly resources: number;
16
+ }) => void;
17
+ }
18
+ export declare const createEvaluate: (reviewer: PermissionReviewer | undefined, options: EvaluateOptions) => ((event: EvaluationEvent) => Promise<void>);
@@ -0,0 +1,20 @@
1
+ import { Option, Schema } from "effect";
2
+ const CommandMetadata = Schema.Struct({ command: Schema.NonEmptyString });
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) => async (event) => {
5
+ if (event.effect !== "ask" || event.action !== "shell" || reviewer === undefined)
6
+ return;
7
+ const command = commandFrom(event.metadata);
8
+ if (event.resources.length > 1 && command === undefined) {
9
+ options.report?.({ effect: "ask", reason: "unavailable", resources: event.resources.length });
10
+ return;
11
+ }
12
+ const resources = command === undefined
13
+ ? event.resources
14
+ : [...new Set([...event.resources, command])];
15
+ const review = await reviewer.review({ action: event.action, resources });
16
+ const reason = review.effect === "ask" ? review.reason : "judged";
17
+ options.report?.({ effect: review.effect, reason, resources: resources.length });
18
+ if (review.effect === "allow")
19
+ event.effect = "allow";
20
+ };
@@ -0,0 +1,3 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ declare const _default: Plugin.Plugin;
3
+ export default _default;
@@ -0,0 +1,91 @@
1
+ import { createTypeSafeClient, createZenClient, DEFAULT_TYPESAFE_MODEL } from "../core.js";
2
+ import { Plugin } from "@opencode/plugin";
3
+ import { Effect } from "effect";
4
+ import { loadJevvyConfig } from "../config.js";
5
+ import { createPermissionReviewer } from "../engine.js";
6
+ import { createEvaluate } from "./evaluate.js";
7
+ import { credentialToken, OPENCODE_INTEGRATION, resolveCredential, } from "./credentials.js";
8
+ const describeConnection = (connection) => connection.type === "env"
9
+ ? { kind: "env", envName: connection.name }
10
+ : { kind: "credential" };
11
+ const storedCredentialFrom = (credential) => {
12
+ if (credential?.type === "key") {
13
+ return { type: "key", key: credential.key, configuration: credential.configuration };
14
+ }
15
+ if (credential?.type === "oauth")
16
+ return { type: "oauth", access: credential.access };
17
+ return undefined;
18
+ };
19
+ export default Plugin.define({
20
+ id: "jevvy.permissions",
21
+ async setup(ctx) {
22
+ const permissionConfig = await Effect.runPromise(loadJevvyConfig());
23
+ if (permissionConfig.kind === "invalid") {
24
+ console.warn("[jevvy] jevvy.jsonc failed validation, auto-approval is disabled", permissionConfig.message);
25
+ }
26
+ const ports = {
27
+ activeIntegration: (id) => ctx.integration.connection.active(id),
28
+ resolveIntegration: async (connection) => {
29
+ try {
30
+ return storedCredentialFrom(await ctx.integration.connection.resolve(connection));
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ },
36
+ readEnv: (name) => process.env[name],
37
+ };
38
+ const apiKeys = permissionConfig.kind === "invalid" ? {} : permissionConfig.apiKeys;
39
+ const provider = permissionConfig.kind === "invalid" ? "auto" : permissionConfig.provider;
40
+ const credential = await resolveCredential(ports, provider, describeConnection, apiKeys);
41
+ let client;
42
+ if (credential.kind === "typesafe") {
43
+ client = createTypeSafeClient(credential.key);
44
+ }
45
+ if (credential.kind === "zen") {
46
+ if (credential.origin === "opencode") {
47
+ client = {
48
+ async evaluate(request, signal) {
49
+ const connection = await ctx.integration.connection.active(OPENCODE_INTEGRATION);
50
+ const stored = connection === undefined
51
+ ? undefined
52
+ : storedCredentialFrom(await ctx.integration.connection.resolve(connection));
53
+ const token = credentialToken(stored);
54
+ if (token === undefined)
55
+ throw new Error("OpenCode login is unavailable");
56
+ return createZenClient(token, credential.model).evaluate(request, signal);
57
+ },
58
+ };
59
+ }
60
+ else {
61
+ client = createZenClient(credential.key, credential.model);
62
+ }
63
+ }
64
+ const questions = permissionConfig.kind === "custom" ? permissionConfig.questions : undefined;
65
+ const reviewer = client === undefined || permissionConfig.kind === "invalid"
66
+ ? undefined
67
+ : await createPermissionReviewer(client, { questions });
68
+ if (client === undefined && permissionConfig.kind !== "invalid") {
69
+ console.warn("[jevvy] no provider credential, native permission prompts remain unchanged");
70
+ }
71
+ const evaluate = createEvaluate(reviewer, {
72
+ report: (entry) => console.info("[jevvy] permission review", entry),
73
+ });
74
+ const permissions = await ctx.permission.hook("evaluate", evaluate);
75
+ let model;
76
+ if (credential.kind === "zen")
77
+ model = credential.model;
78
+ if (credential.kind === "typesafe")
79
+ model = DEFAULT_TYPESAFE_MODEL;
80
+ console.info("[jevvy] loaded", {
81
+ provider: credential.kind,
82
+ model,
83
+ questions: permissionConfig.kind === "custom" ? "custom" : "calibrated-defaults",
84
+ autoApproval: reviewer === undefined ? "disabled" : "enabled",
85
+ });
86
+ return async () => {
87
+ await permissions.dispose();
88
+ await reviewer?.dispose();
89
+ };
90
+ },
91
+ });
@@ -0,0 +1,64 @@
1
+ import type { NoulQuestion } from "./core.ts";
2
+ export interface ApprovalThreshold {
3
+ readonly direction: "atLeast" | "atMost";
4
+ readonly value: number;
5
+ }
6
+ export interface ApprovalQuestion extends NoulQuestion {
7
+ readonly threshold: ApprovalThreshold;
8
+ }
9
+ export type ApprovalQuestions = Readonly<Record<string, ApprovalQuestion>>;
10
+ /**
11
+ * The shipped inquiry set is calibrated as one unit with its thresholds and
12
+ * pinned models. Rewording or overriding any entry creates a custom policy.
13
+ */
14
+ export declare const defaultApprovalQuestions: {
15
+ harmful: {
16
+ type: "noul";
17
+ instructions: string;
18
+ criteria: {
19
+ false: string;
20
+ true: string;
21
+ };
22
+ threshold: {
23
+ direction: "atMost";
24
+ value: number;
25
+ };
26
+ };
27
+ sensitive: {
28
+ type: "noul";
29
+ instructions: string;
30
+ criteria: {
31
+ false: string;
32
+ true: string;
33
+ };
34
+ threshold: {
35
+ direction: "atMost";
36
+ value: number;
37
+ };
38
+ };
39
+ untrusted: {
40
+ type: "noul";
41
+ instructions: string;
42
+ criteria: {
43
+ false: string;
44
+ true: string;
45
+ };
46
+ threshold: {
47
+ direction: "atMost";
48
+ value: number;
49
+ };
50
+ };
51
+ obscured: {
52
+ type: "noul";
53
+ instructions: string;
54
+ criteria: {
55
+ false: string;
56
+ true: string;
57
+ };
58
+ threshold: {
59
+ direction: "atMost";
60
+ value: number;
61
+ };
62
+ };
63
+ };
64
+ export declare const toNoulQuestions: (questions: ApprovalQuestions) => Record<string, NoulQuestion>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The shipped inquiry set is calibrated as one unit with its thresholds and
3
+ * pinned models. Rewording or overriding any entry creates a custom policy.
4
+ */
5
+ export const defaultApprovalQuestions = {
6
+ harmful: {
7
+ type: "noul",
8
+ instructions: "How likely is executing this shell command exactly as written to cause meaningful harm that a person should review first? Count destructive or hard-to-reverse changes, including deleting files or source-control state and discarding uncommitted work, as harm. Also count security weakening, privileged system changes, publishing or deploying, substantial cost, and disruption to other users or services. Ordinary read-only commands, common shell builtins, and reversible local development bookkeeping are not harm.",
9
+ criteria: {
10
+ false: "Routine and harmless to execute without human review",
11
+ true: "Meaningfully harmful or consequential enough to require human review",
12
+ },
13
+ threshold: { direction: "atMost", value: 0.35 },
14
+ },
15
+ sensitive: {
16
+ type: "noul",
17
+ instructions: "How likely does this shell command itself clearly target credentials, private data, or security-sensitive material for exposure, transmission, printing, copying, or weakened protection? Do not infer sensitivity merely because a command reads ordinary source files, repository state, paths, or public remote metadata. Score high only when the command or a conventional target indicates sensitive material.",
18
+ criteria: {
19
+ false: "No credible risk to credentials, private data, or security-sensitive material",
20
+ true: "Credible risk of exposing or weakening protection for sensitive material",
21
+ },
22
+ threshold: { direction: "atMost", value: 0.5 },
23
+ },
24
+ untrusted: {
25
+ type: "noul",
26
+ instructions: "How likely is this shell command to execute code that is fetched, installed, generated, encoded, or otherwise not already present as a trusted local program? Include package installation hooks, pip or npm installs, fetched scripts, and decoded payload execution. Merely downloading data or running an ordinary installed command is not untrusted code execution.",
27
+ criteria: {
28
+ false: "Does not execute newly obtained or concealed code",
29
+ true: "Executes code that is newly obtained, installed, generated, or concealed",
30
+ },
31
+ threshold: { direction: "atMost", value: 0.5 },
32
+ },
33
+ obscured: {
34
+ type: "noul",
35
+ instructions: "How likely is the command's consequential behavior obscured or difficult to determine from the command itself, including encoded payloads, indirect execution, fetched code execution, shell tricks, or misleading construction? Ordinary flags, variables, pipes, familiar shell builtins such as true, and scripts with clear behavior are not obscured merely because they are terse or complex.",
36
+ criteria: {
37
+ false: "Consequential behavior is clear enough to judge from the command",
38
+ true: "Consequential behavior is hidden, indirect, or materially uncertain",
39
+ },
40
+ threshold: { direction: "atMost", value: 0.5 },
41
+ },
42
+ };
43
+ export const toNoulQuestions = (questions) => {
44
+ const result = {};
45
+ for (const [key, question] of Object.entries(questions)) {
46
+ result[key] = {
47
+ type: "noul",
48
+ instructions: question.instructions,
49
+ criteria: question.criteria,
50
+ };
51
+ }
52
+ return result;
53
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@jevvy/permissions",
3
+ "version": "0.0.0",
4
+ "description": "Auto-approve shell permission requests that Jevvy judges harmless",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/PanAchy/jevvy#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/PanAchy/jevvy.git",
11
+ "directory": "packages/permissions"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/PanAchy/jevvy/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "plugin",
19
+ "permissions",
20
+ "agent",
21
+ "jev"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "bin": {
33
+ "jevvy-calibrate": "bin/jevvy-calibrate.mjs"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "bin",
38
+ "LICENSE",
39
+ "THIRD_PARTY_LICENSES.txt"
40
+ ],
41
+ "scripts": {
42
+ "prebuild": "npm run build --workspace=@jevvy/core && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
43
+ "build": "tsc -p tsconfig.build.json && esbuild src/core.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/core.js && node ../../scripts/copy-calibration-assets.mjs",
44
+ "prepack": "npm run build && node ../../scripts/package-readme.mjs prepare",
45
+ "postpack": "node ../../scripts/package-readme.mjs clean"
46
+ },
47
+ "dependencies": {
48
+ "@effect/platform-node": "4.0.0-rc.112",
49
+ "@effect/platform-node-shared": "4.0.0-rc.112",
50
+ "@opencode/client": "2.0.8",
51
+ "@opencode/plugin": "2.0.8",
52
+ "effect": "4.0.0-rc.112",
53
+ "jsonc-parser": "^3.3.1"
54
+ },
55
+ "engines": {
56
+ "node": ">=24"
57
+ }
58
+ }