@okram78/opencode-provider-guard 0.0.0-stage → 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marko Posio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # OpenCode Provider Guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Restricts which providers OpenCode can use in selected directories. Useful for
4
+ keeping personal, pay-as-you-go providers out of work projects. It blocks
5
+ disallowed providers.
6
+
7
+ ![OpenCode's model picker shows models from multiple providers in personal projects and only GitHub Copilot models in work.](assets/provider-guard-demo.gif)
8
+
9
+ ## Compatibility
10
+
11
+ - OpenCode **V2**; tested against `@opencode/plugin` 2.0.24.
12
+ - Bun 1.4.2 for development. Node.js 22.12+ is required for Node-based use; the
13
+ published ESM also works with Bun.
14
+
15
+ ## Build and enable
16
+
17
+ ```sh
18
+ bun install --frozen-lockfile
19
+ bun run check
20
+ ```
21
+
22
+ To enable the plugin, add it to your **global** OpenCode config
23
+ (`~/.config/opencode/opencode.jsonc`):
24
+
25
+ ```jsonc
26
+ {
27
+ "$schema": "https://opencode.ai/config.json",
28
+ "plugins": [
29
+ {
30
+ "package": "/absolute/path/to/opencode-provider-guard/dist",
31
+ "options": {
32
+ "rules": [
33
+ {
34
+ "root": "~/projects/work",
35
+ "allowProviders": ["github-copilot"],
36
+ },
37
+ ],
38
+ },
39
+ },
40
+ ],
41
+ }
42
+ ```
43
+
44
+ Use a global entry so the guard loads in every project. Preserve other config
45
+ settings. After publication, the local path can be replaced with
46
+ `@okram78/opencode-provider-guard@1.0.0`.
47
+
48
+ ## Rules
49
+
50
+ - `root` is an absolute path or `~`/`~/...`; it covers the directory and all
51
+ descendants. Globs, relative paths, and environment-variable expansion are not
52
+ supported.
53
+ - `allowProviders` contains exact, case-sensitive provider IDs (not model IDs).
54
+ An empty list denies all providers.
55
+ - Overlapping rules are intersected, so a nested rule can narrow but never relax
56
+ an outer rule. No matching rule means no restriction.
57
+ - Both lexical and resolved paths are checked, including symlink targets. Invalid
58
+ paths and filesystem errors fail closed.
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ bun install --frozen-lockfile
64
+ bun run check
65
+ npm pack --dry-run
66
+ ```
67
+
68
+ ## License
69
+
70
+ [MIT](LICENSE).
Binary file
@@ -0,0 +1,10 @@
1
+ export interface GuardRule {
2
+ readonly root: string;
3
+ readonly allowProviders: readonly string[];
4
+ }
5
+ export interface GuardOptions {
6
+ readonly rules: readonly GuardRule[];
7
+ }
8
+ /** Reject typos and malformed rules instead of silently weakening protection. */
9
+ export declare function parseOptions(input: unknown): GuardOptions;
10
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAA;CAC3C;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAA;CACrC;AAwBD,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,CAiDzD"}
package/dist/config.js ADDED
@@ -0,0 +1,56 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { ProviderGuardError } from "./errors.js";
3
+ function invalid(message) {
4
+ throw new ProviderGuardError("INVALID_CONFIG", message);
5
+ }
6
+ function record(value, label) {
7
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
8
+ invalid(`${label} must be an object.`);
9
+ }
10
+ return value;
11
+ }
12
+ function keys(value, allowed, label) {
13
+ for (const key of Object.keys(value)) {
14
+ if (!allowed.includes(key))
15
+ invalid(`${label}: unknown option ${JSON.stringify(key)}.`);
16
+ }
17
+ }
18
+ /** Reject typos and malformed rules instead of silently weakening protection. */
19
+ export function parseOptions(input) {
20
+ const options = record(input, "options");
21
+ keys(options, ["rules"], "options");
22
+ if (!Array.isArray(options.rules) || options.rules.length === 0) {
23
+ invalid("options.rules must be a non-empty array.");
24
+ }
25
+ const rules = options.rules.map((input, index) => {
26
+ const label = `rules[${index}]`;
27
+ const rule = record(input, label);
28
+ keys(rule, ["root", "allowProviders"], label);
29
+ if (typeof rule.root !== "string" ||
30
+ rule.root.length === 0 ||
31
+ rule.root !== rule.root.trim() ||
32
+ /[\0*?]/u.test(rule.root) ||
33
+ !(isAbsolute(rule.root) ||
34
+ rule.root === "~" ||
35
+ rule.root.startsWith("~/"))) {
36
+ invalid(`${label}.root must be an absolute path or a ~/ path, without wildcards or surrounding whitespace.`);
37
+ }
38
+ if (!Array.isArray(rule.allowProviders)) {
39
+ invalid(`${label}.allowProviders must be an array of exact provider IDs (an empty array denies all providers).`);
40
+ }
41
+ const providers = rule.allowProviders.map((provider) => {
42
+ if (typeof provider !== "string" ||
43
+ provider !== provider.trim() ||
44
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(provider)) {
45
+ invalid(`${label}.allowProviders must contain exact provider IDs, not model references or patterns.`);
46
+ }
47
+ return provider;
48
+ });
49
+ return Object.freeze({
50
+ root: rule.root,
51
+ allowProviders: Object.freeze([...new Set(providers)]),
52
+ });
53
+ });
54
+ return Object.freeze({ rules: Object.freeze(rules) });
55
+ }
56
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /** Stable error codes for diagnostics without logging prompts or credentials. */
2
+ export type GuardErrorCode = "INVALID_CONFIG" | "PATH_RESOLUTION_FAILED" | "SESSION_LOOKUP_FAILED" | "PROVIDER_BLOCKED";
3
+ export declare class ProviderGuardError extends Error {
4
+ readonly code: GuardErrorCode;
5
+ readonly name = "ProviderGuardError";
6
+ constructor(code: GuardErrorCode, message: string, options?: ErrorOptions);
7
+ }
8
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,MAAM,MAAM,cAAc,GACtB,gBAAgB,GAChB,wBAAwB,GACxB,uBAAuB,GACvB,kBAAkB,CAAA;AAEtB,qBAAa,kBAAmB,SAAQ,KAAK;IAIzC,QAAQ,CAAC,IAAI,EAAE,cAAc;IAH/B,SAAkB,IAAI,wBAAuB;gBAGlC,IAAI,EAAE,cAAc,EAC7B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,YAAY;CAIzB"}
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ export class ProviderGuardError extends Error {
2
+ code;
3
+ name = "ProviderGuardError";
4
+ constructor(code, message, options) {
5
+ super(`[opencode-provider-guard:${code}] ${message}`, options);
6
+ this.code = code;
7
+ }
8
+ }
9
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAOA,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAIhC;IAHO,IAAI,GAAG,oBAAoB,CAAA;IAE7C,YACW,IAAoB,EAC7B,OAAe,EACf,OAAsB;QAEtB,KAAK,CAAC,4BAA4B,IAAI,KAAK,OAAO,EAAE,EAAE,OAAO,CAAC,CAAA;QAJrD,SAAI,GAAJ,IAAI,CAAgB;IAK/B,CAAC;CACF"}
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ type GuardContext = Pick<Plugin.Context, "options" | "location" | "provider" | "model" | "session">;
3
+ /** Kept separate from the entrypoint so contract tests need no running service. */
4
+ export declare function setupGuard(ctx: GuardContext): Promise<() => Promise<void>>;
5
+ export {};
6
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAM9C,KAAK,YAAY,GAAG,IAAI,CACtB,MAAM,CAAC,OAAO,EACd,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,CAC1D,CAAA;AAqBD,mFAAmF;AACnF,wBAAsB,UAAU,CAC9B,GAAG,EAAE,YAAY,GAChB,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAkF9B"}
package/dist/guard.js ADDED
@@ -0,0 +1,83 @@
1
+ import { ProviderGuardError } from "./errors.js";
2
+ import { permits, PolicyEngine } from "./policy.js";
3
+ async function dispose(registrations) {
4
+ const results = await Promise.allSettled([...registrations]
5
+ .reverse()
6
+ .map((registration) => Promise.resolve().then(() => registration.dispose())));
7
+ const errors = results
8
+ .filter((result) => result.status === "rejected")
9
+ .map((result) => result.reason);
10
+ if (errors.length)
11
+ throw new AggregateError(errors, "Provider guard registration cleanup failed.");
12
+ }
13
+ /** Kept separate from the entrypoint so contract tests need no running service. */
14
+ export async function setupGuard(ctx) {
15
+ const engine = new PolicyEngine(ctx.options);
16
+ const registrations = [];
17
+ const check = async (event) => {
18
+ let session;
19
+ try {
20
+ session = await ctx.session.get({ sessionID: event.sessionID });
21
+ }
22
+ catch (cause) {
23
+ throw new ProviderGuardError("SESSION_LOOKUP_FAILED", "Cannot determine the session location; refusing the model request.", { cause });
24
+ }
25
+ // ctx.location belongs to the plugin instance, not necessarily this session.
26
+ // Never apply a different project's canonical path to an unrelated session.
27
+ const directories = [session.location.directory];
28
+ if (session.projectID === ctx.location.project.id) {
29
+ directories.push(ctx.location.project.canonical);
30
+ }
31
+ const policy = await engine.evaluate(directories);
32
+ if (!permits(policy, event.model.providerID)) {
33
+ throw new ProviderGuardError("PROVIDER_BLOCKED", `Provider ${JSON.stringify(event.model.providerID)} is blocked in ${JSON.stringify(session.location.directory)}. Matching roots: ${policy.matchedRoots.map((root) => JSON.stringify(root)).join(", ")}. Allowed providers: ${policy.allowProviders?.join(", ") || "none"}. Select an allowed provider; no automatic fallback is performed.`);
34
+ }
35
+ };
36
+ try {
37
+ // Register even outside protected roots: a session may later move inside.
38
+ for (const name of [
39
+ "context",
40
+ "compaction",
41
+ "generate",
42
+ "title",
43
+ "model.request",
44
+ "http.request",
45
+ "experimental.ws.handshake",
46
+ "experimental.ws.send",
47
+ ]) {
48
+ registrations.push(await ctx.session.hook(name, check));
49
+ }
50
+ const policy = await engine.evaluate([
51
+ ctx.location.directory,
52
+ ctx.location.project.canonical,
53
+ ]);
54
+ if (policy.allowProviders !== undefined) {
55
+ registrations.push(await ctx.provider.transform((editor) => {
56
+ for (const { provider } of editor.list()) {
57
+ if (!permits(policy, provider.id))
58
+ editor.remove(provider.id);
59
+ }
60
+ }));
61
+ registrations.push(await ctx.model.transform((editor) => {
62
+ for (const model of editor.list()) {
63
+ // V2's DeepMutable maps branded IDs as object types; their runtime
64
+ // values are strings. String() avoids an unsafe type assertion.
65
+ const providerID = String(model.providerID);
66
+ if (!permits(policy, providerID))
67
+ editor.remove(providerID, String(model.id));
68
+ }
69
+ }));
70
+ }
71
+ }
72
+ catch (error) {
73
+ try {
74
+ await dispose(registrations);
75
+ }
76
+ catch (cleanupError) {
77
+ throw new AggregateError([error, cleanupError], "Provider guard setup and cleanup failed.");
78
+ }
79
+ throw error;
80
+ }
81
+ return () => dispose(registrations);
82
+ }
83
+ //# sourceMappingURL=guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ export type { GuardOptions, GuardRule } from "./config.js";
3
+ export { ProviderGuardError } from "./errors.js";
4
+ export type { GuardErrorCode } from "./errors.js";
5
+ declare const _default: Plugin.Plugin;
6
+ export default _default;
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAGzC,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;;AAEjD,wBAGE"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { setupGuard } from "./guard.js";
3
+ export { ProviderGuardError } from "./errors.js";
4
+ export default Plugin.define({
5
+ id: "opencode-provider-guard",
6
+ setup: setupGuard,
7
+ });
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAGvC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAGhD,eAAe,MAAM,CAAC,MAAM,CAAC;IAC3B,EAAE,EAAE,yBAAyB;IAC7B,KAAK,EAAE,UAAU;CAClB,CAAC,CAAA"}
@@ -0,0 +1,11 @@
1
+ export declare function expandHome(value: string, home?: string): string;
2
+ /** Component-aware containment: /work never matches /work-personal. */
3
+ export declare function containsPath(root: string, directory: string): boolean;
4
+ /**
5
+ * Resolve symlinks, including existing ancestors of a not-yet-created path.
6
+ * Permission errors, dangling links and symlink loops are not treated as misses.
7
+ */
8
+ export declare function physicalPath(value: string): Promise<string>;
9
+ /** Keep both spellings so symlinks cannot remove a lexical restriction. */
10
+ export declare function pathAliases(value: string): Promise<readonly string[]>;
11
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AASA,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAY,GAAG,MAAM,CAIlE;AAED,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAMrE;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkCjE;AAED,2EAA2E;AAC3E,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAE3E"}
package/dist/paths.js ADDED
@@ -0,0 +1,63 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { ProviderGuardError } from "./errors.js";
5
+ function missing(error) {
6
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
7
+ }
8
+ export function expandHome(value, home = homedir()) {
9
+ if (value === "~")
10
+ return home;
11
+ if (value.startsWith("~/"))
12
+ return `${home}${sep}${value.slice(2)}`;
13
+ return value;
14
+ }
15
+ /** Component-aware containment: /work never matches /work-personal. */
16
+ export function containsPath(root, directory) {
17
+ const suffix = relative(root, directory);
18
+ return (suffix === "" ||
19
+ (suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)));
20
+ }
21
+ /**
22
+ * Resolve symlinks, including existing ancestors of a not-yet-created path.
23
+ * Permission errors, dangling links and symlink loops are not treated as misses.
24
+ */
25
+ export async function physicalPath(value) {
26
+ if (!isAbsolute(value) || value.includes("\0")) {
27
+ throw new ProviderGuardError("PATH_RESOLUTION_FAILED", "Expected an absolute filesystem path.");
28
+ }
29
+ let current = value;
30
+ const segments = [];
31
+ try {
32
+ for (;;) {
33
+ try {
34
+ return resolve(await realpath(current), ...segments);
35
+ }
36
+ catch (error) {
37
+ if (!missing(error))
38
+ throw error;
39
+ // realpath reports ENOENT for dangling links too. Do not walk past one.
40
+ const info = await lstat(current).catch((statError) => {
41
+ if (!missing(statError))
42
+ throw statError;
43
+ return undefined;
44
+ });
45
+ if (info?.isSymbolicLink())
46
+ throw error;
47
+ const parent = dirname(current);
48
+ if (parent === current)
49
+ throw error;
50
+ segments.unshift(relative(parent, current));
51
+ current = parent;
52
+ }
53
+ }
54
+ }
55
+ catch (cause) {
56
+ throw new ProviderGuardError("PATH_RESOLUTION_FAILED", `Cannot resolve ${JSON.stringify(value)}; refusing to assume it is unprotected.`, { cause });
57
+ }
58
+ }
59
+ /** Keep both spellings so symlinks cannot remove a lexical restriction. */
60
+ export async function pathAliases(value) {
61
+ return [...new Set([resolve(value), await physicalPath(value)])];
62
+ }
63
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ export interface GuardPolicy {
2
+ readonly matchedRoots: readonly string[];
3
+ /** undefined means unrestricted; an empty list means deny all. */
4
+ readonly allowProviders: readonly string[] | undefined;
5
+ }
6
+ export declare function permits(policy: GuardPolicy, providerID: string): boolean;
7
+ /** All matching rules intersect: a nested rule cannot relax its parent. */
8
+ export declare class PolicyEngine {
9
+ #private;
10
+ constructor(options: unknown);
11
+ evaluate(directories: readonly string[]): Promise<GuardPolicy>;
12
+ }
13
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IACxC,kEAAkE;IAClE,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;CACvD;AAED,wBAAgB,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAKxE;AAED,2EAA2E;AAC3E,qBAAa,YAAY;;gBAGX,OAAO,EAAE,OAAO;IAOtB,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC;CAuBrE"}
package/dist/policy.js ADDED
@@ -0,0 +1,34 @@
1
+ import { parseOptions } from "./config.js";
2
+ import { containsPath, expandHome, pathAliases } from "./paths.js";
3
+ export function permits(policy, providerID) {
4
+ return (policy.allowProviders === undefined ||
5
+ policy.allowProviders.includes(providerID));
6
+ }
7
+ /** All matching rules intersect: a nested rule cannot relax its parent. */
8
+ export class PolicyEngine {
9
+ #rules;
10
+ constructor(options) {
11
+ this.#rules = parseOptions(options).rules.map((rule) => ({
12
+ ...rule,
13
+ root: expandHome(rule.root),
14
+ }));
15
+ }
16
+ async evaluate(directories) {
17
+ // No cached filesystem/session state: moves and changed symlink targets are
18
+ // re-evaluated on the next request, rather than on the next plugin reload.
19
+ const paths = (await Promise.all(directories.map(pathAliases))).flat();
20
+ const matches = await Promise.all(this.#rules.map(async (rule) => {
21
+ const roots = await pathAliases(rule.root);
22
+ return roots.some((root) => paths.some((directory) => containsPath(root, directory)))
23
+ ? rule
24
+ : undefined;
25
+ }));
26
+ const rules = matches.filter((rule) => rule !== undefined);
27
+ const first = rules[0];
28
+ return {
29
+ matchedRoots: rules.map((rule) => rule.root),
30
+ allowProviders: first?.allowProviders.filter((provider) => rules.every((rule) => rule.allowProviders.includes(provider))),
31
+ };
32
+ }
33
+ }
34
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.js","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAkB,MAAM,aAAa,CAAA;AAC1D,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAQlE,MAAM,UAAU,OAAO,CAAC,MAAmB,EAAE,UAAkB;IAC7D,OAAO,CACL,MAAM,CAAC,cAAc,KAAK,SAAS;QACnC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,UAAU,CAAC,CAC3C,CAAA;AACH,CAAC;AAED,2EAA2E;AAC3E,MAAM,OAAO,YAAY;IACd,MAAM,CAAsB;IAErC,YAAY,OAAgB;QAC1B,IAAI,CAAC,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACvD,GAAG,IAAI;YACP,IAAI,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC;SAC5B,CAAC,CAAC,CAAA;IACL,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,WAA8B;QAC3C,4EAA4E;QAC5E,2EAA2E;QAC3E,MAAM,KAAK,GAAG,CAAC,MAAM,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACtE,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YAC7B,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YAC1C,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CACzB,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CACzD;gBACC,CAAC,CAAC,IAAI;gBACN,CAAC,CAAC,SAAS,CAAA;QACf,CAAC,CAAC,CACH,CAAA;QACD,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,SAAS,CAAC,CAAA;QAC1D,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;QACtB,OAAO;YACL,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;YAC5C,cAAc,EAAE,KAAK,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CACxD,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAC9D;SACF,CAAA;IACH,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@okram78/opencode-provider-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Path-based provider allowlists for OpenCode V2",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/okram78/opencode-provider-guard"
10
+ },
11
+ "homepage": "https://github.com/okram78/opencode-provider-guard#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/okram78/opencode-provider-guard/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.12"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./schema.json": "./schema.json"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "schema.json",
34
+ "README.md",
35
+ "assets",
36
+ "LICENSE"
37
+ ],
38
+ "keywords": [
39
+ "opencode",
40
+ "opencode-plugin",
41
+ "provider",
42
+ "guard",
43
+ "copilot"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "test:coverage": "vitest run --coverage",
50
+ "test:entrypoint": "node scripts/check-entrypoint.mjs",
51
+ "format": "prettier --write .",
52
+ "format:check": "prettier --check .",
53
+ "check": "bun run format:check && bun run typecheck && bun run test:coverage && bun run build && bun run test:entrypoint",
54
+ "prepack": "bun run build"
55
+ },
56
+ "peerDependencies": {
57
+ "@opencode/plugin": "2.0.24"
58
+ },
59
+ "devDependencies": {
60
+ "@opencode/plugin": "2.0.24",
61
+ "@opencode/schema": "2.0.24",
62
+ "@types/node": "^22.0.0",
63
+ "@vitest/coverage-v8": "^5.0.3",
64
+ "ajv": "^8.20.0",
65
+ "effect": "4.0.0-rc.112",
66
+ "prettier": "^3.0.0",
67
+ "typescript": "^5.9.0",
68
+ "vitest": "^5.0.3"
69
+ },
70
+ "packageManager": "bun@1.4.2"
71
+ }
package/schema.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "OpenCode Provider Guard options",
4
+ "description": "Path-based exact provider allowlists. Matching rules intersect.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["rules"],
8
+ "properties": {
9
+ "rules": {
10
+ "type": "array",
11
+ "minItems": 1,
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["root", "allowProviders"],
16
+ "properties": {
17
+ "root": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "description": "An absolute directory or ~/ path, including its descendants. No globbing. Paths are validated for the host platform at runtime.",
21
+ "pattern": "^(?!\\s)(?!.*[\\u0000*?])(?!.*\\s$)(?:~(?:/.*)?|/.*|[A-Za-z]:[\\\\/].*|\\\\\\\\[^\\\\]+\\\\[^\\\\]+.*)$"
22
+ },
23
+ "allowProviders": {
24
+ "type": "array",
25
+ "description": "Exact, case-sensitive provider IDs. Empty means deny all. Duplicates are harmless and deduplicated at runtime.",
26
+ "items": {
27
+ "type": "string",
28
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$(?![\\s\\S])"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
package/src/config.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { isAbsolute } from "node:path"
2
+ import { ProviderGuardError } from "./errors.js"
3
+
4
+ export interface GuardRule {
5
+ readonly root: string
6
+ readonly allowProviders: readonly string[]
7
+ }
8
+
9
+ export interface GuardOptions {
10
+ readonly rules: readonly GuardRule[]
11
+ }
12
+
13
+ function invalid(message: string): never {
14
+ throw new ProviderGuardError("INVALID_CONFIG", message)
15
+ }
16
+
17
+ function record(value: unknown, label: string): Record<string, unknown> {
18
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
19
+ invalid(`${label} must be an object.`)
20
+ }
21
+ return value as Record<string, unknown>
22
+ }
23
+
24
+ function keys(
25
+ value: Record<string, unknown>,
26
+ allowed: string[],
27
+ label: string,
28
+ ) {
29
+ for (const key of Object.keys(value)) {
30
+ if (!allowed.includes(key))
31
+ invalid(`${label}: unknown option ${JSON.stringify(key)}.`)
32
+ }
33
+ }
34
+
35
+ /** Reject typos and malformed rules instead of silently weakening protection. */
36
+ export function parseOptions(input: unknown): GuardOptions {
37
+ const options = record(input, "options")
38
+ keys(options, ["rules"], "options")
39
+ if (!Array.isArray(options.rules) || options.rules.length === 0) {
40
+ invalid("options.rules must be a non-empty array.")
41
+ }
42
+
43
+ const rules = options.rules.map((input: unknown, index: number) => {
44
+ const label = `rules[${index}]`
45
+ const rule = record(input, label)
46
+ keys(rule, ["root", "allowProviders"], label)
47
+ if (
48
+ typeof rule.root !== "string" ||
49
+ rule.root.length === 0 ||
50
+ rule.root !== rule.root.trim() ||
51
+ /[\0*?]/u.test(rule.root) ||
52
+ !(
53
+ isAbsolute(rule.root) ||
54
+ rule.root === "~" ||
55
+ rule.root.startsWith("~/")
56
+ )
57
+ ) {
58
+ invalid(
59
+ `${label}.root must be an absolute path or a ~/ path, without wildcards or surrounding whitespace.`,
60
+ )
61
+ }
62
+ if (!Array.isArray(rule.allowProviders)) {
63
+ invalid(
64
+ `${label}.allowProviders must be an array of exact provider IDs (an empty array denies all providers).`,
65
+ )
66
+ }
67
+ const providers = rule.allowProviders.map((provider: unknown) => {
68
+ if (
69
+ typeof provider !== "string" ||
70
+ provider !== provider.trim() ||
71
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(provider)
72
+ ) {
73
+ invalid(
74
+ `${label}.allowProviders must contain exact provider IDs, not model references or patterns.`,
75
+ )
76
+ }
77
+ return provider
78
+ })
79
+ return Object.freeze({
80
+ root: rule.root,
81
+ allowProviders: Object.freeze([...new Set(providers)]),
82
+ })
83
+ })
84
+ return Object.freeze({ rules: Object.freeze(rules) })
85
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ /** Stable error codes for diagnostics without logging prompts or credentials. */
2
+ export type GuardErrorCode =
3
+ | "INVALID_CONFIG"
4
+ | "PATH_RESOLUTION_FAILED"
5
+ | "SESSION_LOOKUP_FAILED"
6
+ | "PROVIDER_BLOCKED"
7
+
8
+ export class ProviderGuardError extends Error {
9
+ override readonly name = "ProviderGuardError"
10
+
11
+ constructor(
12
+ readonly code: GuardErrorCode,
13
+ message: string,
14
+ options?: ErrorOptions,
15
+ ) {
16
+ super(`[opencode-provider-guard:${code}] ${message}`, options)
17
+ }
18
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import type { Registration } from "@opencode/plugin/promise/registration"
3
+ import type { SessionRequest } from "@opencode/plugin/promise/session"
4
+ import { ProviderGuardError } from "./errors.js"
5
+ import { permits, PolicyEngine } from "./policy.js"
6
+
7
+ type GuardContext = Pick<
8
+ Plugin.Context,
9
+ "options" | "location" | "provider" | "model" | "session"
10
+ >
11
+ type RequestIdentity = Pick<SessionRequest, "sessionID" | "model">
12
+
13
+ async function dispose(registrations: readonly Registration[]): Promise<void> {
14
+ const results = await Promise.allSettled(
15
+ [...registrations]
16
+ .reverse()
17
+ .map((registration) =>
18
+ Promise.resolve().then(() => registration.dispose()),
19
+ ),
20
+ )
21
+ const errors = results
22
+ .filter((result) => result.status === "rejected")
23
+ .map((result) => result.reason as unknown)
24
+ if (errors.length)
25
+ throw new AggregateError(
26
+ errors,
27
+ "Provider guard registration cleanup failed.",
28
+ )
29
+ }
30
+
31
+ /** Kept separate from the entrypoint so contract tests need no running service. */
32
+ export async function setupGuard(
33
+ ctx: GuardContext,
34
+ ): Promise<() => Promise<void>> {
35
+ const engine = new PolicyEngine(ctx.options)
36
+ const registrations: Registration[] = []
37
+
38
+ const check = async (event: RequestIdentity): Promise<void> => {
39
+ let session: Awaited<ReturnType<typeof ctx.session.get>>
40
+ try {
41
+ session = await ctx.session.get({ sessionID: event.sessionID })
42
+ } catch (cause) {
43
+ throw new ProviderGuardError(
44
+ "SESSION_LOOKUP_FAILED",
45
+ "Cannot determine the session location; refusing the model request.",
46
+ { cause },
47
+ )
48
+ }
49
+ // ctx.location belongs to the plugin instance, not necessarily this session.
50
+ // Never apply a different project's canonical path to an unrelated session.
51
+ const directories = [session.location.directory]
52
+ if (session.projectID === ctx.location.project.id) {
53
+ directories.push(ctx.location.project.canonical)
54
+ }
55
+ const policy = await engine.evaluate(directories)
56
+ if (!permits(policy, event.model.providerID)) {
57
+ throw new ProviderGuardError(
58
+ "PROVIDER_BLOCKED",
59
+ `Provider ${JSON.stringify(event.model.providerID)} is blocked in ${JSON.stringify(session.location.directory)}. Matching roots: ${policy.matchedRoots.map((root) => JSON.stringify(root)).join(", ")}. Allowed providers: ${policy.allowProviders?.join(", ") || "none"}. Select an allowed provider; no automatic fallback is performed.`,
60
+ )
61
+ }
62
+ }
63
+
64
+ try {
65
+ // Register even outside protected roots: a session may later move inside.
66
+ for (const name of [
67
+ "context",
68
+ "compaction",
69
+ "generate",
70
+ "title",
71
+ "model.request",
72
+ "http.request",
73
+ "experimental.ws.handshake",
74
+ "experimental.ws.send",
75
+ ] as const) {
76
+ registrations.push(await ctx.session.hook(name, check))
77
+ }
78
+
79
+ const policy = await engine.evaluate([
80
+ ctx.location.directory,
81
+ ctx.location.project.canonical,
82
+ ])
83
+ if (policy.allowProviders !== undefined) {
84
+ registrations.push(
85
+ await ctx.provider.transform((editor) => {
86
+ for (const { provider } of editor.list()) {
87
+ if (!permits(policy, provider.id)) editor.remove(provider.id)
88
+ }
89
+ }),
90
+ )
91
+ registrations.push(
92
+ await ctx.model.transform((editor) => {
93
+ for (const model of editor.list()) {
94
+ // V2's DeepMutable maps branded IDs as object types; their runtime
95
+ // values are strings. String() avoids an unsafe type assertion.
96
+ const providerID = String(model.providerID)
97
+ if (!permits(policy, providerID))
98
+ editor.remove(providerID, String(model.id))
99
+ }
100
+ }),
101
+ )
102
+ }
103
+ } catch (error) {
104
+ try {
105
+ await dispose(registrations)
106
+ } catch (cleanupError) {
107
+ throw new AggregateError(
108
+ [error, cleanupError],
109
+ "Provider guard setup and cleanup failed.",
110
+ )
111
+ }
112
+ throw error
113
+ }
114
+
115
+ return () => dispose(registrations)
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { setupGuard } from "./guard.js"
3
+
4
+ export type { GuardOptions, GuardRule } from "./config.js"
5
+ export { ProviderGuardError } from "./errors.js"
6
+ export type { GuardErrorCode } from "./errors.js"
7
+
8
+ export default Plugin.define({
9
+ id: "opencode-provider-guard",
10
+ setup: setupGuard,
11
+ })
package/src/paths.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { lstat, realpath } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
4
+ import { ProviderGuardError } from "./errors.js"
5
+
6
+ function missing(error: unknown): boolean {
7
+ return error instanceof Error && "code" in error && error.code === "ENOENT"
8
+ }
9
+
10
+ export function expandHome(value: string, home = homedir()): string {
11
+ if (value === "~") return home
12
+ if (value.startsWith("~/")) return `${home}${sep}${value.slice(2)}`
13
+ return value
14
+ }
15
+
16
+ /** Component-aware containment: /work never matches /work-personal. */
17
+ export function containsPath(root: string, directory: string): boolean {
18
+ const suffix = relative(root, directory)
19
+ return (
20
+ suffix === "" ||
21
+ (suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix))
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Resolve symlinks, including existing ancestors of a not-yet-created path.
27
+ * Permission errors, dangling links and symlink loops are not treated as misses.
28
+ */
29
+ export async function physicalPath(value: string): Promise<string> {
30
+ if (!isAbsolute(value) || value.includes("\0")) {
31
+ throw new ProviderGuardError(
32
+ "PATH_RESOLUTION_FAILED",
33
+ "Expected an absolute filesystem path.",
34
+ )
35
+ }
36
+ let current = value
37
+ const segments: string[] = []
38
+ try {
39
+ for (;;) {
40
+ try {
41
+ return resolve(await realpath(current), ...segments)
42
+ } catch (error) {
43
+ if (!missing(error)) throw error
44
+ // realpath reports ENOENT for dangling links too. Do not walk past one.
45
+ const info = await lstat(current).catch((statError: unknown) => {
46
+ if (!missing(statError)) throw statError
47
+ return undefined
48
+ })
49
+ if (info?.isSymbolicLink()) throw error
50
+ const parent = dirname(current)
51
+ if (parent === current) throw error
52
+ segments.unshift(relative(parent, current))
53
+ current = parent
54
+ }
55
+ }
56
+ } catch (cause) {
57
+ throw new ProviderGuardError(
58
+ "PATH_RESOLUTION_FAILED",
59
+ `Cannot resolve ${JSON.stringify(value)}; refusing to assume it is unprotected.`,
60
+ { cause },
61
+ )
62
+ }
63
+ }
64
+
65
+ /** Keep both spellings so symlinks cannot remove a lexical restriction. */
66
+ export async function pathAliases(value: string): Promise<readonly string[]> {
67
+ return [...new Set([resolve(value), await physicalPath(value)])]
68
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { parseOptions, type GuardRule } from "./config.js"
2
+ import { containsPath, expandHome, pathAliases } from "./paths.js"
3
+
4
+ export interface GuardPolicy {
5
+ readonly matchedRoots: readonly string[]
6
+ /** undefined means unrestricted; an empty list means deny all. */
7
+ readonly allowProviders: readonly string[] | undefined
8
+ }
9
+
10
+ export function permits(policy: GuardPolicy, providerID: string): boolean {
11
+ return (
12
+ policy.allowProviders === undefined ||
13
+ policy.allowProviders.includes(providerID)
14
+ )
15
+ }
16
+
17
+ /** All matching rules intersect: a nested rule cannot relax its parent. */
18
+ export class PolicyEngine {
19
+ readonly #rules: readonly GuardRule[]
20
+
21
+ constructor(options: unknown) {
22
+ this.#rules = parseOptions(options).rules.map((rule) => ({
23
+ ...rule,
24
+ root: expandHome(rule.root),
25
+ }))
26
+ }
27
+
28
+ async evaluate(directories: readonly string[]): Promise<GuardPolicy> {
29
+ // No cached filesystem/session state: moves and changed symlink targets are
30
+ // re-evaluated on the next request, rather than on the next plugin reload.
31
+ const paths = (await Promise.all(directories.map(pathAliases))).flat()
32
+ const matches = await Promise.all(
33
+ this.#rules.map(async (rule) => {
34
+ const roots = await pathAliases(rule.root)
35
+ return roots.some((root) =>
36
+ paths.some((directory) => containsPath(root, directory)),
37
+ )
38
+ ? rule
39
+ : undefined
40
+ }),
41
+ )
42
+ const rules = matches.filter((rule) => rule !== undefined)
43
+ const first = rules[0]
44
+ return {
45
+ matchedRoots: rules.map((rule) => rule.root),
46
+ allowProviders: first?.allowProviders.filter((provider) =>
47
+ rules.every((rule) => rule.allowProviders.includes(provider)),
48
+ ),
49
+ }
50
+ }
51
+ }