opencode-castlegate 0.1.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 Martins6
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 ADDED
@@ -0,0 +1,155 @@
1
+ # opencode-castlegate
2
+
3
+ OpenCode plugin that **auto-approves tool calls when they match your session intent**, and surfaces mismatches to you before execution.
4
+
5
+ See [`SPEC.md`](./SPEC.md) for the full architecture and behavioral contract. Security model: [`docs/security-model.md`](./docs/security-model.md). Config schema: [`docs/config.md`](./docs/config.md).
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ opencode plugin add opencode-castlegate@latest
13
+ ```
14
+
15
+ That is it. The plugin works **zero-config**: when no `model` block is set in `opencode.json`, it uses opencode's session-default model for the lightweight intent validator. If you want to pin a specific fast/cheap model, add a single block:
16
+
17
+ ```jsonc
18
+ // opencode.json
19
+ {
20
+ "$schema": "https://opencode.ai/config.json",
21
+ "plugin": ["opencode-castlegate@latest"],
22
+ "opencode-castlegate": {
23
+ "model": { "providerID": "anthropic", "modelID": "claude-haiku-4-5" }
24
+ }
25
+ }
26
+ ```
27
+
28
+ See [`docs/config.md`](./docs/config.md) for the full schema.
29
+
30
+ ---
31
+
32
+ ## Local development
33
+
34
+ This repo is for development against a host OpenCode installation. The plugin is loaded as a package from the host project's `.opencode/package.json`. Do not copy individual source files into `.opencode/plugins/`: the entry point imports the rest of the source tree.
35
+
36
+ ### 1. Clone and install dependencies
37
+
38
+ ```bash
39
+ git clone https://github.com/Martins6/opencode-castlegate.git
40
+ cd opencode-castlegate
41
+ npm install
42
+ ```
43
+
44
+ ### 2. Verify the build is healthy
45
+
46
+ ```bash
47
+ npm run typecheck
48
+ npm test
49
+ ```
50
+
51
+ Tests use Node's built-in test runner via `--experimental-strip-types`. bun is **not** required for development; opencode itself runs the plugin under bun at runtime.
52
+
53
+ ### 3. Link it into a host project
54
+
55
+ Pick a project where you want to use opencode-castlegate, then:
56
+
57
+ ```bash
58
+ # from inside the host project
59
+ mkdir -p .opencode
60
+ ```
61
+
62
+ Create `.opencode/package.json` with a `file:` dependency:
63
+
64
+ ```json
65
+ {
66
+ "dependencies": {
67
+ "opencode-castlegate": "file:/absolute/path/to/opencode-castlegate"
68
+ }
69
+ }
70
+ ```
71
+
72
+ OpenCode runs `bun install` on startup to resolve `.opencode/package.json`, then loads the package named in the `plugin` array. No `.opencode/plugins/` file is needed for this package. If you previously copied `opencode-castlegate.ts` into that directory, remove it so the plugin is not registered twice.
73
+
74
+ ### 4. Configure
75
+
76
+ Add the `opencode-castlegate` block to your host project's `opencode.json`:
77
+
78
+ ```json
79
+ {
80
+ "$schema": "https://opencode.ai/config.json",
81
+ "plugin": ["opencode-castlegate"],
82
+ "opencode-castlegate": {
83
+ "model": { "providerID": "anthropic", "modelID": "claude-haiku-4-5" },
84
+ "intent": { "maxChars": 4000, "compactThreshold": 0.8, "maxRecentMessages": 20 },
85
+ "validate": {
86
+ "tools": ["bash", "edit", "write", "webfetch"],
87
+ "skip": ["read", "glob", "grep"],
88
+ "requireApprovalOnMismatch": true,
89
+ "failOpenOnError": true,
90
+ "cacheTtlMs": 30000,
91
+ "mode": "llm"
92
+ },
93
+ "storage": { "directory": ".opencode/intent" },
94
+ "logging": true
95
+ }
96
+ }
97
+ ```
98
+
99
+ See [`docs/config.md`](./docs/config.md) for the full schema.
100
+
101
+ ### 5. Manual smoke test
102
+
103
+ Run the bundled end-to-end script (no opencode server required):
104
+
105
+ ```bash
106
+ node --experimental-strip-types scripts/manual-e2e.ts
107
+ ```
108
+
109
+ You should see output like:
110
+
111
+ ```
112
+ plugin loaded: [ 'dispose', 'event', 'tool.execute.before', 'tool' ]
113
+ digest file created: true
114
+ logs after validation: 0
115
+ intent_context tool result: ...
116
+ ```
117
+
118
+ ### 6. Iterating
119
+
120
+ After editing files in this repo, restart the host project's OpenCode session. It reruns the `.opencode` dependency install and reloads the package on startup.
121
+
122
+ If the host project is still using a stale local dependency, run `bun install` from its `.opencode/` directory before restarting the session.
123
+
124
+ ## Project layout
125
+
126
+ ```
127
+ opencode-castlegate/
128
+ ├── SPEC.md Architecture + behavioral contract
129
+ ├── README.md This file (dev install)
130
+ ├── package.json
131
+ ├── tsconfig.json
132
+ ├── src/
133
+ │ ├── index.ts Plugin entry — wires all hooks
134
+ │ ├── config.ts Zod schema + parser
135
+ │ ├── errors.ts CastlegateIntentMismatchError
136
+ │ ├── store.ts Atomic digest store on disk
137
+ │ ├── intent.ts updateDigest (lightweight LLM merge)
138
+ │ ├── validate.ts validateToolCall + LRU cache
139
+ │ ├── log.ts Structured event logger
140
+ │ ├── prompts/
141
+ │ │ ├── intent-update.ts Digest-merge prompt + parser
142
+ │ │ └── intent-validate.ts Validator prompt + parser + arg summarizer
143
+ │ └── tools/
144
+ │ └── intent-context.ts Custom intent_context tool
145
+ ├── test/ Node --test suite (34 tests)
146
+ ├── scripts/
147
+ │ └── manual-e2e.ts Boot fake client, exercise hooks
148
+ └── docs/
149
+ ├── config.md Full config reference
150
+ └── security-model.md Threat model + logging + caching
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT
package/SPEC.md ADDED
@@ -0,0 +1,182 @@
1
+ # SPEC — castlegate
2
+
3
+ ## 1. Purpose
4
+
5
+ OpenCode plugin that gates tool calls against the user's stated session intent. A lightweight model maintains a rolling **intent digest** for the session. Each gated `tool.execute.before` call is validated against that digest:
6
+
7
+ - **Match** → tool runs (no user prompt).
8
+ - **Mismatch** → tool is blocked with a tagged error; the main agent surfaces the request to the user via its `question` tool.
9
+
10
+ ## 2. Non-goals
11
+
12
+ - Not a sandbox. castlegate does not enforce OS-level isolation.
13
+ - Not a static permission system. OpenCode's `permission` config handles pattern rules.
14
+ - Not a replacement for the user's own judgment. The user can always say "yes" via the question tool.
15
+
16
+ ## 3. Architecture
17
+
18
+ ```
19
+ ┌────────────────────────────────────────────────┐
20
+ │ Plugin runtime │
21
+ │ │
22
+ │ event hook │
23
+ │ └─► session.created → seed digest file │
24
+ │ └─► message.updated → schedule digest │
25
+ │ update (1s debounce) │
26
+ │ └─► session.deleted → drop digest file │
27
+ │ │
28
+ │ tool.execute.before │
29
+ │ └─► shouldValidate(tool, cfg) │
30
+ │ └─► loadDigest(dir, sessionId) │
31
+ │ └─► validateToolCall(...) ←─ LLM call │
32
+ │ └─► match: allow | mismatch: throw tagged │
33
+ │ │
34
+ │ tool.intent_context (custom) │
35
+ │ └─► returns current digest for the session │
36
+ │ │
37
+ │ dispose │
38
+ │ └─► cancel timers, clear validator cache │
39
+ └────────────────────────────────────────────────┘
40
+ │ │
41
+ ▼ ▼
42
+ .opencode/intent/<sid>.md Sidecar session
43
+ (on-disk digest, atomic) (validator LLM calls)
44
+ ```
45
+
46
+ ### 3.1 Sidecar session
47
+
48
+ The validator LLM runs in a hidden, persistent opencode session created via `client.session.create({body: {title: "[castlegate] validator sidecar"}})`. This keeps validator calls out of the user's session history and amortizes session setup. One sidecar per plugin instance.
49
+
50
+ ### 3.2 Storage format
51
+
52
+ `.opencode/intent/<sanitizedSessionId>.md`:
53
+
54
+ ```
55
+ ---
56
+ digestVersion: 4
57
+ lastMessageId: msg_41
58
+ updatedAt: 2026-09-05T12:00:00.000Z
59
+ charCount: 612
60
+ ---
61
+
62
+ <markdown body, <= intent.maxChars>
63
+ ```
64
+
65
+ Writes are atomic (temp file + `fs.rename`, with `fsync` of the temp file before the rename).
66
+
67
+ ### 3.3 Validator cache
68
+
69
+ `validateToolCall` results are cached in-memory keyed by `sha256(tool + "\0" + JSON.stringify(args) + "\0" + digestVersion)` for `validate.cacheTtlMs`. Cache auto-invalidates when the digest version bumps (next `intent.updated`).
70
+
71
+ ## 4. Public surface
72
+
73
+ ### 4.1 Plugin entry
74
+
75
+ ```ts
76
+ // src/index.ts
77
+ export const CastlegatePlugin: Plugin = async (input, options) => { ... };
78
+ export default CastlegatePlugin;
79
+ export { CastlegateIntentMismatchError, CASTLEGATE_TAG };
80
+ ```
81
+
82
+ ### 4.2 Custom tool
83
+
84
+ ```
85
+ intent_context
86
+ args: { format?: "summary" | "raw" }
87
+ returns: { title, output, metadata: { version, lastMessageId, updatedAt, charCount } }
88
+ ```
89
+
90
+ ### 4.3 Tagged error
91
+
92
+ ```ts
93
+ class CastlegateIntentMismatchError extends Error {
94
+ readonly tag = "CASTLEGATE_INTENT_MISMATCH";
95
+ readonly tool: string;
96
+ readonly reason: string;
97
+ readonly severity: "low" | "medium" | "high";
98
+ }
99
+ ```
100
+
101
+ Error message format (must stay stable for the main agent to parse):
102
+
103
+ ```
104
+ CASTLEGATE_INTENT_MISMATCH: tool '<name>' did not match the current session intent.
105
+ Reason: <reason>
106
+ Severity: <severity>
107
+ Action required: surface this to the user via the question tool and wait for explicit approval before retrying the tool call.
108
+ ```
109
+
110
+ ### 4.4 Config
111
+
112
+ See `docs/config.md`. The plugin is loaded as `opencode-castlegate` and its options use the top-level `opencode-castlegate` key in `opencode.json`.
113
+
114
+ ## 5. Behavioral contracts
115
+
116
+ ### 5.1 What `shouldValidate` returns
117
+
118
+ | Condition | Result |
119
+ |---|---|
120
+ | `tool` ∈ `validate.skip` | `false` (skip) |
121
+ | `validate.tools` set and `tool` ∉ `validate.tools` | `false` (skip) |
122
+ | otherwise | `true` |
123
+
124
+ ### 5.2 What the validator returns
125
+
126
+ ```json
127
+ { "match": <bool>, "reason": <string>, "severity": "low" | "medium" | "high" }
128
+ ```
129
+
130
+ Parsed from the lightweight model's text response. Accepts fenced JSON, surrounding prose, or plain JSON. Defaults severity to `medium` on unrecognized values.
131
+
132
+ ### 5.3 What happens on mismatch when `requireApprovalOnMismatch: true`
133
+
134
+ `CastlegateIntentMismatchError` is thrown from `tool.execute.before`. OpenCode converts this to a tool error for the agent. The bash command never executes. The agent is expected to call its `question` tool with a yes/no/always choice.
135
+
136
+ ### 5.4 What happens on validator failure (`failOpenOnError`)
137
+
138
+ | `failOpenOnError` | Behavior |
139
+ |---|---|
140
+ | `true` (default) | Log warning, allow tool call |
141
+ | `false` | Throw `CastlegateIntentMismatchError` with severity `medium` (or `high` for `bash`) |
142
+
143
+ ### 5.5 What happens when no digest exists yet
144
+
145
+ `validate.skip_no_digest` is logged and the tool call is allowed through. The intent ingestion loop will populate the digest before the next gated tool call (1s debounce).
146
+
147
+ ## 6. Lifecycle
148
+
149
+ | Event | Handler |
150
+ |---|---|
151
+ | `session.created` | Initialize empty digest file (`digestVersion: 1`, body `""`) |
152
+ | `message.updated` | Debounce 1s, then `updateDigest` |
153
+ | `message.part.updated` / `message.part.removed` / `message.removed` | Same debounced path |
154
+ | `session.deleted` | Delete digest file, drop per-session state |
155
+ | `dispose()` | Cancel timers, clear cache |
156
+
157
+ ## 7. Logging
158
+
159
+ All events flow through `client.app.log` with `service: "castlegate"`. Tool argument values are never logged; only the keys (e.g. `["command"]`) and the validator's reason string. See `docs/security-model.md` for the full event list.
160
+
161
+ ## 8. Failure modes
162
+
163
+ | Mode | Symptom | Mitigation |
164
+ |---|---|---|
165
+ | Lightweight model call throws | `validate.error` event; `failOpenOnError` decides outcome | Configurable |
166
+ | Lightweight model returns invalid JSON | parser throws → treated as validator error | Same |
167
+ | Digest file corrupted | `loadDigest` returns `null` → treated as no digest → tool allowed | Atomic writes + version field help detect corruption; future: checksum |
168
+ | Two updates race | Last write wins (atomic rename) | Acceptable; digest version makes stale reads detectable |
169
+ | Opencode kills the sidecar session | Next validator call creates a fresh one (cache holds the stale id but call errors) | Acceptable; fallback decision applies |
170
+
171
+ ## 9. Future work
172
+
173
+ - Decision journal: persist user-approved exceptions so the validator does not re-block after explicit approval.
174
+ - Heuristic-only mode (`validate.mode: "heuristic"`) for zero-cost validation against the digest.
175
+ - Compaction hook integration so the digest survives long sessions without losing the anchor intent.
176
+ - Native `permission.ask` API integration once upstream issues #37164 / #34327 land.
177
+
178
+ ## 10. Compatibility
179
+
180
+ - opencode ≥ 1.0 (uses `@opencode-ai/plugin` ≥ 1.18.0, SDK ≥ 1.18.0)
181
+ - zod 4.1.x (locked; newer minor versions change the `_zod.version.minor` tag and break `tool()` typings)
182
+ - Node ≥ 20 for development (type-check, tests). Runtime is bun, shipped via opencode.
package/docs/config.md ADDED
@@ -0,0 +1,53 @@
1
+ # opencode-castlegate configuration
2
+
3
+ The `opencode-castlegate` block in `opencode.json` (or `.opencode/opencode.json`) accepts the following keys. Defaults are shown.
4
+
5
+ ```ts
6
+ {
7
+ model?: {
8
+ providerID: string, // optional — defaults to opencode's session-default model
9
+ modelID: string, // optional — defaults to opencode's session-default model
10
+ },
11
+ intent: {
12
+ maxChars: number, // default 4000 — hard cap on digest size
13
+ compactThreshold: number, // default 0.8 — fraction of maxChars at which a compact run is requested
14
+ maxRecentMessages: number, // default 20 — how many recent messages to feed the lightweight model per digest update
15
+ },
16
+ validate: {
17
+ tools?: string[], // optional allowlist; when set, ONLY these tools are validated
18
+ skip: string[], // default [] — these tools are NEVER validated
19
+ requireApprovalOnMismatch: boolean, // default true — throw tagged error on mismatch
20
+ failOpenOnError: boolean, // default true — allow tool call when validator errors
21
+ cacheTtlMs: number, // default 30000 — skip repeat-validating identical calls within this window
22
+ mode: "llm" | "heuristic" | "both", // default "llm" — validator mode (heuristic-only is a future enhancement)
23
+ },
24
+ storage: {
25
+ directory: string, // default ".opencode/intent" — relative paths resolve against the project directory
26
+ },
27
+ logging: boolean, // default true — emit structured events via client.app.log
28
+ }
29
+ ```
30
+
31
+ > **Zero-config default.** When `model` is omitted, the plugin omits the `model` field from `client.session.prompt` and lets opencode resolve the session-default model. This is the recommended starting point — most users will never need to set `model`. Override only if you want to pin a specific fast/cheap model for the lightweight validator.
32
+
33
+ ## Behavior notes
34
+
35
+ - **`validate.tools`** is the strongest signal: set it to gate only the tools you consider high-risk. If unset, every tool is validated (subject to `skip`).
36
+ - **`validate.skip`** is honored even when `validate.tools` is set.
37
+ - **`intent.maxChars`** is enforced after each digest update: any output over the limit is truncated. Compaction is requested (via the lightweight model) when the digest is at or above `intent.compactThreshold * intent.maxChars`.
38
+ - **`validate.cacheTtlMs`** is a *defensive* cache, keyed by `hash(tool, args, digestVersion)`. Set to `0` to disable.
39
+ - **`storage.directory`** is resolved against the plugin's working directory at runtime, so you can point it outside the project if desired.
40
+
41
+ ## Minimal config
42
+
43
+ Zero-config (no `opencode-castlegate` block at all) works out of the box — the plugin uses opencode's session-default model.
44
+
45
+ To pin a specific fast/cheap validator model:
46
+
47
+ ```json
48
+ {
49
+ "opencode-castlegate": {
50
+ "model": { "providerID": "anthropic", "modelID": "claude-haiku-4-5" }
51
+ }
52
+ }
53
+ ```
@@ -0,0 +1,78 @@
1
+ # castlegate security model
2
+
3
+ ## Threat model
4
+
5
+ castlegate is a **defense-in-depth** layer on top of OpenCode's permission system. It is designed to catch the common case where an agent does something surprising — not to replace OS-level sandboxing, network policy, or secret management.
6
+
7
+ In scope:
8
+
9
+ - An agent drifting away from the user's stated session intent and making tool calls the user would not have approved.
10
+ - Repeated trivial mistakes that the user has already corrected once in the conversation.
11
+ - "Drive-by" tool calls (e.g. installing packages, running unrelated tests) that creep into a focused refactor.
12
+
13
+ Out of scope:
14
+
15
+ - A *deliberately* compromised model that intends to exfiltrate data — it can craft intent-matching phrasings or call tools castlegate is told to skip.
16
+ - Prompt injection from tool *outputs* (e.g. a poisoned webfetch page that instructs the model). castlegate validates tool *call args*, not what the model does with results.
17
+ - Resource exhaustion from the lightweight model itself; rate-limit upstream if this matters.
18
+
19
+ ## Fail-open vs fail-closed
20
+
21
+ `validate.failOpenOnError` controls what happens when the validator itself fails (network blip, model error, malformed response):
22
+
23
+ | `failOpenOnError` | Behavior on validator error |
24
+ |---|---|
25
+ | `true` (default) | Log a warning, allow the tool call to proceed |
26
+ | `false` | Throw `CastlegateIntentMismatchError` as if it were a mismatch |
27
+
28
+ **Default is fail-open** so transient outages don't break sessions. Flip to `false` for high-risk projects where you would rather the agent stop and check in than proceed with a stale (or missing) intent digest.
29
+
30
+ ## What gets logged
31
+
32
+ `logging: true` emits structured events through `client.app.log` with `service: "castlegate"`:
33
+
34
+ | Event | When | Notable fields |
35
+ |---|---|---|
36
+ | `session.initialized` | `session.created` | `sessionID` |
37
+ | `intent.updated` | digest update succeeds | `version`, `compacted`, `intentShift`, `chars` |
38
+ | `intent.update_error` | digest update throws | `error` |
39
+ | `validate.match` | tool call matches digest | `tool`, `cached`, `severity` |
40
+ | `validate.mismatch` | tool call does not match | `tool`, `reason`, `severity`, `argKeys` |
41
+ | `validate.error` | validator itself failed | `tool`, `error` |
42
+ | `validate.skip_no_digest` | no digest yet for session | `tool` |
43
+ | `validate.digest_load_error` | could not load digest from disk | `tool`, `error` |
44
+
45
+ Tool arguments are **never** logged in full — only the keys (e.g. `["command"]`, `["filePath"]`) and the validator's `reason`.
46
+
47
+ ## Permission flow
48
+
49
+ When a mismatch is detected and `validate.requireApprovalOnMismatch` is `true`:
50
+
51
+ 1. castlegate throws `CastlegateIntentMismatchError` from `tool.execute.before`.
52
+ 2. OpenCode surfaces the error to the agent as a tool error.
53
+ 3. The agent is expected to use the `question` tool to ask the user for explicit approval before retrying the call.
54
+ 4. The tool call does **not** execute until the user approves.
55
+
56
+ This works because of OpenCode's separation between agent error handling and tool execution: a thrown error in `tool.execute.before` aborts the tool call before it reaches the runtime.
57
+
58
+ When upstream opencode lands a native `permission.ask` API inside `tool.execute.before` (issues #37164, #34327), castlegate will switch to using it without changing this contract.
59
+
60
+ ## Cache and replay
61
+
62
+ `validate.cacheTtlMs` (default 30s) caches validation results keyed by `hash(tool, args, digestVersion)`. This means:
63
+
64
+ - A repeated call within the window reuses the prior decision. This is intentional: if the agent retries an identical call after you approved it once, castlegate doesn't redo the LLM round-trip.
65
+ - The cache is invalidated whenever the digest version bumps (after `intent.updated`). New context → new decisions.
66
+
67
+ ## Files written
68
+
69
+ - `.opencode/intent/<sessionId>.md` — the digest for each session. Format is plain markdown with a YAML-ish front-matter header. **Not gitignored by default;** add it to `.gitignore` if you don't want digest snapshots in source control.
70
+ - No other files are written.
71
+
72
+ ## Outbound network
73
+
74
+ Every validated tool call adds one outbound call to the configured lightweight model. On long sessions with many tool calls, this can dominate cost. Mitigations:
75
+
76
+ - `validate.skip` for tools you don't care about (e.g. `read`, `glob`, `grep`).
77
+ - `validate.cacheTtlMs` (already on by default).
78
+ - `validate.tools` to whitelist only the high-risk tools.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "opencode-castlegate",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that auto-approves tool calls when they match the user's session intent, validated by a lightweight model.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "node --test --experimental-strip-types --test-reporter=spec test/store.test.ts test/validate.test.ts test/intent.test.ts test/config.test.ts test/errors.test.ts test/prompt-body.test.ts"
13
+ },
14
+ "keywords": [
15
+ "opencode",
16
+ "plugin",
17
+ "agent",
18
+ "guardrail",
19
+ "intent",
20
+ "auto-approve"
21
+ ],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/Martins6/opencode-castlegate.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/Martins6/opencode-castlegate/issues"
29
+ },
30
+ "homepage": "https://github.com/Martins6/opencode-castlegate#readme",
31
+ "files": [
32
+ "src",
33
+ "README.md",
34
+ "SPEC.md",
35
+ "docs",
36
+ "LICENSE"
37
+ ],
38
+ "dependencies": {
39
+ "@opencode-ai/plugin": "^1.18.0",
40
+ "zod": "4.1.8"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.0.0",
44
+ "typescript": "^5.6.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }
package/src/config.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+
3
+ export const ModelConfigSchema = z.object({
4
+ providerID: z.string().min(1),
5
+ modelID: z.string().min(1),
6
+ });
7
+ export type ModelConfig = z.infer<typeof ModelConfigSchema>;
8
+
9
+ export const IntentConfigSchema = z.object({
10
+ maxChars: z.number().int().positive().default(4000),
11
+ compactThreshold: z.number().min(0.1).max(1).default(0.8),
12
+ maxRecentMessages: z.number().int().positive().default(20),
13
+ });
14
+ export type IntentConfig = z.infer<typeof IntentConfigSchema>;
15
+
16
+ export const ValidateConfigSchema = z.object({
17
+ tools: z.array(z.string()).optional(),
18
+ skip: z.array(z.string()).default([]),
19
+ requireApprovalOnMismatch: z.boolean().default(true),
20
+ failOpenOnError: z.boolean().default(true),
21
+ cacheTtlMs: z.number().int().nonnegative().default(30_000),
22
+ mode: z.enum(["llm", "heuristic", "both"]).default("llm"),
23
+ });
24
+ export type ValidateConfig = z.infer<typeof ValidateConfigSchema>;
25
+
26
+ export const StorageConfigSchema = z.object({
27
+ directory: z.string().default(".opencode/intent"),
28
+ });
29
+ export type StorageConfig = z.infer<typeof StorageConfigSchema>;
30
+
31
+ export const CastlegateConfigSchema = z.object({
32
+ model: ModelConfigSchema.optional(),
33
+ intent: IntentConfigSchema.default(() => ({
34
+ maxChars: 4000,
35
+ compactThreshold: 0.8,
36
+ maxRecentMessages: 20,
37
+ })),
38
+ validate: ValidateConfigSchema.default(() => ({
39
+ skip: [],
40
+ requireApprovalOnMismatch: true,
41
+ failOpenOnError: true,
42
+ cacheTtlMs: 30_000,
43
+ mode: "llm" as const,
44
+ })),
45
+ storage: StorageConfigSchema.default(() => ({ directory: ".opencode/intent" })),
46
+ logging: z.boolean().default(true),
47
+ });
48
+ export type CastlegateConfig = z.infer<typeof CastlegateConfigSchema>;
49
+
50
+ export function parseConfig(raw: unknown): CastlegateConfig {
51
+ return CastlegateConfigSchema.parse(raw);
52
+ }
53
+
54
+ export function safeParseConfig(raw: unknown): {
55
+ ok: true;
56
+ config: CastlegateConfig;
57
+ } | {
58
+ ok: false;
59
+ error: z.ZodError;
60
+ } {
61
+ const result = CastlegateConfigSchema.safeParse(raw);
62
+ if (result.success) return { ok: true, config: result.data };
63
+ return { ok: false, error: result.error };
64
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,28 @@
1
+ export const CASTLEGATE_TAG = "CASTLEGATE_INTENT_MISMATCH";
2
+
3
+ export class CastlegateIntentMismatchError extends Error {
4
+ readonly tag = CASTLEGATE_TAG;
5
+ readonly tool: string;
6
+ readonly reason: string;
7
+ readonly severity: "low" | "medium" | "high";
8
+
9
+ constructor(tool: string, reason: string, severity: "low" | "medium" | "high") {
10
+ const message =
11
+ `${CASTLEGATE_TAG}: tool '${tool}' did not match the current session intent.\n` +
12
+ `Reason: ${reason}\n` +
13
+ `Severity: ${severity}\n` +
14
+ `Action required: surface this to the user via the question tool and wait for explicit approval before retrying the tool call.`;
15
+ super(message);
16
+ this.name = "CastlegateIntentMismatchError";
17
+ this.tool = tool;
18
+ this.reason = reason;
19
+ this.severity = severity;
20
+ }
21
+
22
+ static isMismatch(err: unknown): err is CastlegateIntentMismatchError {
23
+ return (
24
+ err instanceof CastlegateIntentMismatchError ||
25
+ (err instanceof Error && err.message.startsWith(`${CASTLEGATE_TAG}:`))
26
+ );
27
+ }
28
+ }