@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 +21 -0
- package/README.md +69 -2
- package/assets/provider-guard-demo.gif +0 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/guard.d.ts +6 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +83 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +63 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +34 -0
- package/dist/policy.js.map +1 -0
- package/package.json +69 -4
- package/schema.json +35 -0
- package/src/config.ts +85 -0
- package/src/errors.ts +18 -0
- package/src/guard.ts +116 -0
- package/src/index.ts +11 -0
- package/src/paths.ts +68 -0
- package/src/policy.ts +51 -0
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
|
-
#
|
|
1
|
+
# OpenCode Provider Guard
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
package/dist/config.d.ts
ADDED
|
@@ -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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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/guard.d.ts
ADDED
|
@@ -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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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/paths.d.ts
ADDED
|
@@ -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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"}
|
package/dist/policy.d.ts
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|