@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,153 @@
1
+ import { Schema } from "effect";
2
+ const CalibrationCorpusSchema = Schema.Struct({
3
+ meta: Schema.optional(Schema.Struct({
4
+ note: Schema.optional(Schema.String),
5
+ repeatCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
6
+ })),
7
+ commands: Schema.Array(Schema.Struct({
8
+ command: Schema.NonEmptyString,
9
+ want: Schema.Literals(["allow", "ask", "must-ask"]),
10
+ })).check(Schema.isMinLength(1)),
11
+ repeats: Schema.optional(Schema.Array(Schema.NonEmptyString)),
12
+ });
13
+ export const parseCalibrationCorpus = (value, name) => {
14
+ let decoded;
15
+ try {
16
+ decoded = Schema.decodeUnknownSync(CalibrationCorpusSchema, { onExcessProperty: "error" })(value);
17
+ }
18
+ catch {
19
+ throw new Error(`${name} does not match the calibration corpus schema`);
20
+ }
21
+ if ((decoded.repeats?.length ?? 0) > 0 && decoded.meta?.repeatCount === undefined) {
22
+ throw new Error(`${name} must set meta.repeatCount when repeats are present`);
23
+ }
24
+ return decoded;
25
+ };
26
+ export const buildCalibrationPlan = (corpora) => {
27
+ const seen = new Map();
28
+ const plan = [];
29
+ for (const named of corpora) {
30
+ const byCommand = new Map(named.corpus.commands.map((entry) => [entry.command, entry]));
31
+ for (const entry of named.corpus.commands) {
32
+ const previous = seen.get(entry.command);
33
+ if (previous !== undefined) {
34
+ throw new Error(`duplicate command in ${previous} and ${named.name}: ${entry.command}`);
35
+ }
36
+ seen.set(entry.command, named.name);
37
+ plan.push({ corpus: named.name, kind: "base", ...entry });
38
+ }
39
+ for (const command of named.corpus.repeats ?? []) {
40
+ const entry = byCommand.get(command);
41
+ if (entry === undefined)
42
+ throw new Error(`${named.name} repeats a command not present in its commands array: ${command}`);
43
+ for (let probe = 1; probe <= (named.corpus.meta?.repeatCount ?? 0); probe++) {
44
+ plan.push({ corpus: named.name, kind: "repeat", ...entry, probe });
45
+ }
46
+ }
47
+ }
48
+ return plan;
49
+ };
50
+ const mean = (numbers) => numbers.reduce((sum, number) => sum + number, 0) / numbers.length;
51
+ const deviation = (numbers) => {
52
+ const average = mean(numbers);
53
+ return Math.sqrt(mean(numbers.map((number) => (number - average) ** 2)));
54
+ };
55
+ const percentile = (numbers, fraction) => numbers[Math.min(numbers.length - 1, Math.floor(numbers.length * fraction))] ?? 0;
56
+ export const summarizeCalibration = (meta, records, completedAt = new Date().toISOString()) => {
57
+ const results = records.filter((record) => record.status === "result");
58
+ const errors = records.filter((record) => record.status === "error");
59
+ const mustAskLeaks = results.filter((record) => record.want === "must-ask" && record.effect === "allow");
60
+ const labelMismatches = results.filter((record) => (record.want === "allow") !== (record.effect === "allow"));
61
+ const modelMismatches = results.filter((record) => record.model !== meta.requestedModel);
62
+ const matrix = {
63
+ allow: { allow: 0, ask: 0 },
64
+ ask: { allow: 0, ask: 0 },
65
+ "must-ask": { allow: 0, ask: 0 },
66
+ };
67
+ for (const record of results)
68
+ matrix[record.want][record.effect]++;
69
+ const repeatedSummary = [];
70
+ const repeated = new Map();
71
+ for (const record of results) {
72
+ if (record.kind !== "repeat")
73
+ continue;
74
+ const key = `${record.corpus}\u0000${record.command}`;
75
+ const rows = repeated.get(key) ?? [];
76
+ rows.push(record);
77
+ repeated.set(key, rows);
78
+ }
79
+ for (const rows of repeated.values()) {
80
+ const first = rows[0];
81
+ if (first === undefined)
82
+ continue;
83
+ const allRows = results.filter((record) => record.corpus === first.corpus && record.command === first.command);
84
+ const verdicts = allRows.reduce((counts, record) => {
85
+ counts[record.effect]++;
86
+ return counts;
87
+ }, { allow: 0, ask: 0 });
88
+ for (const name of Object.keys(meta.questions)) {
89
+ const values = allRows.flatMap((record) => record.answers[name] === undefined ? [] : [record.answers[name]]);
90
+ if (values.length === 0)
91
+ continue;
92
+ repeatedSummary.push({
93
+ corpus: first.corpus,
94
+ command: first.command,
95
+ question: name,
96
+ mean: mean(values),
97
+ minimum: Math.min(...values),
98
+ maximum: Math.max(...values),
99
+ standardDeviation: deviation(values),
100
+ verdicts,
101
+ });
102
+ }
103
+ }
104
+ const latencies = results.map((record) => record.ms).sort((left, right) => left - right);
105
+ const failed = errors.length > 0 || mustAskLeaks.length > 0 || modelMismatches.length > 0;
106
+ const summary = {
107
+ summary: true,
108
+ completedAt,
109
+ result: failed ? "failed" : "passed",
110
+ failed,
111
+ counts: {
112
+ planned: meta.calls,
113
+ completed: records.length,
114
+ errors: errors.length,
115
+ mustAskLeaks: mustAskLeaks.length,
116
+ modelMismatches: modelMismatches.length,
117
+ labelMismatches: labelMismatches.length,
118
+ },
119
+ matrix,
120
+ repeated: repeatedSummary,
121
+ mustAskLeaks: mustAskLeaks.map((record) => ({ corpus: record.corpus, command: record.command })),
122
+ labelMismatches: labelMismatches.map((record) => ({
123
+ corpus: record.corpus,
124
+ command: record.command,
125
+ want: record.want,
126
+ effect: record.effect,
127
+ })),
128
+ modelMismatches: modelMismatches.map((record) => ({
129
+ corpus: record.corpus,
130
+ command: record.command,
131
+ requested: meta.requestedModel,
132
+ served: record.model,
133
+ })),
134
+ errors: errors.map((record) => ({ corpus: record.corpus, command: record.command, error: record.error })),
135
+ };
136
+ if (latencies.length === 0)
137
+ return summary;
138
+ return {
139
+ ...summary,
140
+ latency: {
141
+ p50: percentile(latencies, 0.5),
142
+ p90: percentile(latencies, 0.9),
143
+ mean: mean(latencies),
144
+ maximum: latencies.at(-1) ?? 0,
145
+ },
146
+ };
147
+ };
148
+ export const calibrationMetaRecord = (meta) => ({
149
+ schemaVersion: 1,
150
+ meta: true,
151
+ ...meta,
152
+ });
153
+ export const numericAnswers = (answers) => Object.fromEntries(Object.entries(answers).map(([name, answer]) => [name, answer.noul]));
@@ -0,0 +1,59 @@
1
+ import { ConfigProvider, Effect, Schema } from "effect";
2
+ import type { Redacted } from "effect";
3
+ import type { ApprovalQuestions } from "./questions.ts";
4
+ declare const ProviderPreferenceSchema: Schema.Literals<readonly ["auto", "zen", "typesafe"]>;
5
+ export interface ProviderApiKeys {
6
+ readonly zen?: Redacted.Redacted<string>;
7
+ readonly typesafe?: Redacted.Redacted<string>;
8
+ }
9
+ export type ProviderPreference = Schema.Schema.Type<typeof ProviderPreferenceSchema>;
10
+ export type JevvyConfig = {
11
+ readonly kind: "default";
12
+ readonly provider: ProviderPreference;
13
+ readonly apiKeys: ProviderApiKeys;
14
+ } | {
15
+ readonly kind: "custom";
16
+ readonly provider: ProviderPreference;
17
+ readonly apiKeys: ProviderApiKeys;
18
+ readonly questions: ApprovalQuestions;
19
+ } | {
20
+ readonly kind: "invalid";
21
+ readonly message: string;
22
+ };
23
+ export declare const globalJevvyConfigPath: () => string;
24
+ export declare const loadJevvyConfig: (path?: string, environment?: ConfigProvider.ConfigProvider) => Effect.Effect<{
25
+ kind: "default";
26
+ provider: "auto" | "typesafe" | "zen";
27
+ apiKeys: ProviderApiKeys;
28
+ message?: undefined;
29
+ questions?: undefined;
30
+ } | {
31
+ kind: "invalid";
32
+ message: string;
33
+ provider?: undefined;
34
+ apiKeys?: undefined;
35
+ questions?: undefined;
36
+ } | {
37
+ message?: undefined;
38
+ kind: "custom";
39
+ provider: "auto" | "typesafe" | "zen";
40
+ apiKeys: ProviderApiKeys;
41
+ questions: {
42
+ readonly [x: string]: {
43
+ readonly type: "noul";
44
+ readonly instructions: string;
45
+ readonly criteria?: {
46
+ readonly false: string;
47
+ readonly true: string;
48
+ } | undefined;
49
+ readonly threshold: {
50
+ readonly direction: "atLeast" | "atMost";
51
+ readonly value: number;
52
+ };
53
+ };
54
+ };
55
+ } | {
56
+ kind: "invalid";
57
+ message: string;
58
+ }, never, never>;
59
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,111 @@
1
+ import { chmod, readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Config, ConfigProvider, Effect, Option, Schema } from "effect";
5
+ import { parse } from "jsonc-parser";
6
+ const Probability = Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }));
7
+ const CriteriaSchema = Schema.Struct({
8
+ false: Schema.NonEmptyString,
9
+ true: Schema.NonEmptyString,
10
+ });
11
+ const ApprovalQuestionSchema = Schema.Struct({
12
+ type: Schema.Literal("noul"),
13
+ instructions: Schema.NonEmptyString,
14
+ criteria: Schema.optional(CriteriaSchema),
15
+ threshold: Schema.Struct({
16
+ direction: Schema.Literals(["atLeast", "atMost"]),
17
+ value: Probability,
18
+ }),
19
+ });
20
+ const ApprovalQuestionsSchema = Schema.Record(Schema.NonEmptyString, ApprovalQuestionSchema);
21
+ const ProviderPreferenceSchema = Schema.Literals(["auto", "zen", "typesafe"]);
22
+ const ProviderCredentialSchema = Schema.Struct({
23
+ apiKey: Schema.NonEmptyString,
24
+ });
25
+ const JevvyConfigSchema = Schema.Struct({
26
+ $schema: Schema.optional(Schema.String),
27
+ provider: Schema.optional(ProviderPreferenceSchema),
28
+ providers: Schema.optional(Schema.Struct({
29
+ zen: Schema.optional(ProviderCredentialSchema),
30
+ typesafe: Schema.optional(ProviderCredentialSchema),
31
+ })),
32
+ permissions: Schema.optional(Schema.Struct({
33
+ questions: Schema.optional(ApprovalQuestionsSchema),
34
+ })),
35
+ });
36
+ class ConfigMissing extends Schema.TaggedError()("ConfigMissing", {}) {
37
+ }
38
+ class ConfigFileError extends Schema.TaggedError()("ConfigFileError", {
39
+ reason: Schema.Literals(["read", "schema", "permissions"]),
40
+ }) {
41
+ }
42
+ const fileApiKey = (provider) => Config.redacted("apiKey").pipe(Config.nested(provider), Config.nested("providers"));
43
+ const RuntimeConfig = Config.all({
44
+ provider: Config.schema(ProviderPreferenceSchema, "provider"),
45
+ zen: Config.option(fileApiKey("zen").pipe(Config.orElse(() => Config.redacted("OPENCODE_API_KEY")))),
46
+ typesafe: Config.option(fileApiKey("typesafe").pipe(Config.orElse(() => Config.redacted("TYPESAFE_API_KEY")))),
47
+ questions: Config.option(Config.schema(ApprovalQuestionsSchema, ["permissions", "questions"])),
48
+ });
49
+ const hasCredentials = (document) => document.providers?.zen !== undefined || document.providers?.typesafe !== undefined;
50
+ const readDocument = Effect.fn("JevvyConfig.readDocument")(function* (path) {
51
+ const raw = yield* Effect.tryPromise({
52
+ try: () => readFile(path, "utf8"),
53
+ catch: (cause) => {
54
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
55
+ return new ConfigMissing();
56
+ return new ConfigFileError({ reason: "read" });
57
+ },
58
+ });
59
+ const parseErrors = [];
60
+ const decoded = parse(raw, parseErrors, { allowTrailingComma: true, disallowComments: false });
61
+ if (parseErrors.length > 0)
62
+ return yield* new ConfigFileError({ reason: "schema" });
63
+ const document = yield* Schema.decodeUnknownEffect(JevvyConfigSchema, {
64
+ onExcessProperty: "error",
65
+ })(decoded).pipe(Effect.mapError(() => new ConfigFileError({ reason: "schema" })));
66
+ if (process.platform !== "win32" && hasCredentials(document)) {
67
+ const info = yield* Effect.tryPromise({
68
+ try: () => stat(path),
69
+ catch: () => new ConfigFileError({ reason: "read" }),
70
+ });
71
+ if ((info.mode & 0o777) !== 0o600) {
72
+ yield* Effect.tryPromise({
73
+ try: () => chmod(path, 0o600),
74
+ catch: () => new ConfigFileError({ reason: "permissions" }),
75
+ });
76
+ }
77
+ }
78
+ return document;
79
+ });
80
+ const invalidMessage = (reason) => {
81
+ if (reason === "permissions")
82
+ return "credential-bearing jevvy.jsonc could not be secured";
83
+ if (reason === "read")
84
+ return "jevvy.jsonc could not be read";
85
+ return "jevvy.jsonc does not match the configuration schema";
86
+ };
87
+ export const globalJevvyConfigPath = () => {
88
+ const override = process.env.JEVVY_CONFIG?.trim();
89
+ if (override !== undefined && override.length > 0)
90
+ return override;
91
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
92
+ const root = xdg !== undefined && xdg.length > 0 ? xdg : join(homedir(), ".config");
93
+ return join(root, "jevvy", "jevvy.jsonc");
94
+ };
95
+ const load = Effect.fn("JevvyConfig.load")(function* (path, environment) {
96
+ const document = yield* readDocument(path).pipe(Effect.catchTag("ConfigMissing", () => Effect.succeed({})));
97
+ const provider = ConfigProvider.fromUnknown({ provider: "auto", ...document }).pipe(ConfigProvider.orElse(environment));
98
+ const config = yield* RuntimeConfig.parse(provider).pipe(Effect.mapError(() => new ConfigFileError({ reason: "schema" })));
99
+ let apiKeys = {};
100
+ if (Option.isSome(config.zen))
101
+ apiKeys = { zen: config.zen.value };
102
+ if (Option.isSome(config.typesafe))
103
+ apiKeys = { ...apiKeys, typesafe: config.typesafe.value };
104
+ if (Option.isNone(config.questions))
105
+ return { kind: "default", provider: config.provider, apiKeys };
106
+ if (Object.keys(config.questions.value).length === 0) {
107
+ return { kind: "invalid", message: "permissions.questions must contain at least one question" };
108
+ }
109
+ return { kind: "custom", provider: config.provider, apiKeys, questions: config.questions.value };
110
+ }, Effect.catchTag("ConfigFileError", (error) => Effect.succeed({ kind: "invalid", message: invalidMessage(error.reason) })));
111
+ export const loadJevvyConfig = (path = globalJevvyConfigPath(), environment = ConfigProvider.fromEnv()) => load(path, environment);
package/dist/core.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createTypeSafeClient, createZenClient, DEFAULT_TYPESAFE_MODEL, DEFAULT_ZEN_MODEL, } from "@jevvy/core";
2
+ export type { Json, JevClient, JevRequest, JevResult, NoulAnswer, NoulQuestion, } from "@jevvy/core";