opencode-lookout 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import { open, rename, unlink } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { change, owned, parseConfig, validateID } from "./config.mjs";
5
+
6
+ /** @param {string} text Exact file bytes decoded as UTF-8. @returns {string} SHA-256 fingerprint. */
7
+ export const fingerprint = (text) =>
8
+ createHash("sha256").update(text).digest("hex");
9
+ /**
10
+ * Read one fixed config file without following a final-component symlink.
11
+ * @param {string} file Trusted configuration path, never supplied by an RPC caller.
12
+ * @returns {Promise<{text: string, mode: number}>} Validated text and original mode.
13
+ */
14
+ export async function readConfig(file) {
15
+ const limit = 1024 * 1024;
16
+ const invalid =
17
+ "Configuration must be a regular, unlinked file at most 1 MiB.";
18
+ // Validate the descriptor, not the path before open. NONBLOCK prevents a FIFO
19
+ // substituted at this path from hanging before we can reject its file type.
20
+ const handle = await open(
21
+ file,
22
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
23
+ ).catch((error) => {
24
+ if (error.code === "ELOOP") throw new Error(invalid, { cause: error });
25
+ throw error;
26
+ });
27
+ try {
28
+ const stat = await handle.stat();
29
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > limit)
30
+ throw new Error(invalid);
31
+ // A file may grow after fstat. Bound allocation and reads even in that case.
32
+ const bytes = Buffer.alloc(limit + 1);
33
+ let length = 0;
34
+ while (length < bytes.length) {
35
+ const { bytesRead } = await handle.read(
36
+ bytes,
37
+ length,
38
+ bytes.length - length,
39
+ length,
40
+ );
41
+ if (bytesRead === 0) break;
42
+ length += bytesRead;
43
+ }
44
+ if (length > limit) throw new Error(invalid);
45
+ const after = await handle.stat();
46
+ if (
47
+ length !== stat.size ||
48
+ after.size !== stat.size ||
49
+ after.nlink !== 1 ||
50
+ after.mode !== stat.mode ||
51
+ after.mtimeMs !== stat.mtimeMs ||
52
+ after.ctimeMs !== stat.ctimeMs
53
+ )
54
+ throw new Error("Configuration changed; refresh and retry.");
55
+ const text = bytes.toString("utf8", 0, length);
56
+ parseConfig(text);
57
+ return { text, mode: stat.mode & 0o777 };
58
+ } finally {
59
+ await handle.close();
60
+ }
61
+ }
62
+ /**
63
+ * Serialize extension writers and replace the config atomically.
64
+ * Recheck before rename to detect external editors. A portable filesystem cannot
65
+ * compare-and-swap against an uncooperative external writer; see the threat model.
66
+ * @param {string} file Trusted config path.
67
+ * @param {string|null} expected Required fingerprint, or null for an offline command.
68
+ * @param {(text: string) => string} transform Pure transformation that returns valid JSONC.
69
+ * @returns {Promise<void>}
70
+ */
71
+ export async function atomicUpdate(file, expected, transform) {
72
+ const lockPath = file + ".lookout-lock";
73
+ let lock;
74
+ try {
75
+ lock = await open(lockPath, "wx", 0o600);
76
+ } catch {
77
+ throw new Error(
78
+ "Another Lookout write is in progress; retry or inspect the stale lock.",
79
+ );
80
+ }
81
+ const temp = file + `.lookout-${randomUUID()}.tmp`;
82
+ try {
83
+ const { text, mode } = await readConfig(file);
84
+ if (expected && fingerprint(text) !== expected)
85
+ throw new Error(
86
+ "Configuration changed since preview; refresh and retry.",
87
+ );
88
+ const next = transform(text);
89
+ parseConfig(next);
90
+ if (next === text) return;
91
+ const output = await open(temp, "wx", mode);
92
+ try {
93
+ await output.writeFile(next);
94
+ await output.sync();
95
+ } finally {
96
+ await output.close();
97
+ }
98
+ if (fingerprint((await readConfig(file)).text) !== fingerprint(text))
99
+ throw new Error("Configuration changed during write; nothing replaced.");
100
+ await rename(temp, file);
101
+ } finally {
102
+ await unlink(temp).catch((e) => {
103
+ if (e.code !== "ENOENT") throw e;
104
+ });
105
+ await lock.close();
106
+ await unlink(lockPath);
107
+ }
108
+ }
109
+ /** Server-side preview/apply boundary. Tokens and history never contain config text. */
110
+ export class Controller {
111
+ /**
112
+ * @param {string} file Trusted global configuration path.
113
+ * @param {() => Promise<Array<{kind:string,id:string,manageable:boolean}>>} catalog Fresh management eligibility.
114
+ * @param {() => number} now Clock injected for deterministic expiration tests.
115
+ */
116
+ constructor(file, catalog, now = Date.now) {
117
+ this.file = file;
118
+ this.catalog = catalog;
119
+ this.now = now;
120
+ /** @type {Map<string, {input: import('./types').ChangeRequest, hash: string, expires: number}>} */
121
+ this.pending = new Map();
122
+ /** @type {Array<import('./types').ChangeRequest & {time:string}>} */
123
+ this.history = [];
124
+ }
125
+ /** Recheck both the requested kind and current ownership or eligibility.
126
+ * @param {import('./types').ChangeRequest} input Candidate change, revalidated at runtime.
127
+ * @param {string} text Current configuration.
128
+ */
129
+ async authorize(input, text) {
130
+ const { kind, id, enabled } = input;
131
+ validateID(id);
132
+ if (
133
+ !["skill", "mcp", "plugin"].includes(kind) ||
134
+ typeof enabled !== "boolean"
135
+ )
136
+ throw new Error("Unsupported control.");
137
+ if (enabled) {
138
+ if (!owned(text).some((r) => r.kind === kind && r.id === id))
139
+ throw new Error("No owned restriction; this entry is read-only.");
140
+ } else {
141
+ const row = (await this.catalog()).find(
142
+ (r) => r.kind === kind && r.id === id,
143
+ );
144
+ if (!row?.manageable)
145
+ throw new Error(
146
+ "Resource is missing or read-only; refresh before changing it.",
147
+ );
148
+ }
149
+ }
150
+ /** Validate without writing; issue one token bound to the request and file version.
151
+ * @param {import('./types').ChangeRequest} input Requested resource restriction.
152
+ */
153
+ async preview(input) {
154
+ const { text } = await readConfig(this.file);
155
+ await this.authorize(input, text);
156
+ change(text, input.kind, input.id, input.enabled); // Validate the exact prospective edit.
157
+ this.pending.clear(); // A single outstanding preview per instance; bounded and short-lived.
158
+ const token = randomUUID();
159
+ this.pending.set(token, {
160
+ input: { ...input },
161
+ hash: fingerprint(text),
162
+ expires: this.now() + 60_000,
163
+ });
164
+ return {
165
+ token,
166
+ summary: `${input.enabled ? "Remove Lookout restriction for" : "Disable"} ${input.kind} ${input.id}. Scope: global, every project using this server.\n${input.enabled ? "Other restrictions remain in force; this does not grant access." : "Existing conversations retain previously loaded content. Changes may affect other sessions."}\nOnly an exact-ID ${input.kind === "plugin" ? "plugin entry" : "deny policy"} is changed. OpenCode reloads configuration automatically.`,
167
+ };
168
+ }
169
+ /** Consume a preview once, revalidate eligibility, and save only that change.
170
+ * @param {string} token One-use preview token.
171
+ */
172
+ async apply(token) {
173
+ const request = this.pending.get(token);
174
+ this.pending.delete(token);
175
+ if (!request || request.expires <= this.now())
176
+ throw new Error("Preview expired or already used; preview again.");
177
+ const { text } = await readConfig(this.file);
178
+ if (fingerprint(text) !== request.hash)
179
+ throw new Error("Configuration changed since preview; nothing changed.");
180
+ await this.authorize(request.input, text);
181
+ await atomicUpdate(this.file, request.hash, (s) =>
182
+ change(s, request.input.kind, request.input.id, request.input.enabled),
183
+ );
184
+ this.history.push({
185
+ ...request.input,
186
+ time: new Date(this.now()).toISOString(),
187
+ });
188
+ this.history = this.history.slice(-20);
189
+ return {
190
+ saved: true,
191
+ message:
192
+ "Saved. Refresh to verify the effective state; higher-priority policies may override it.",
193
+ };
194
+ }
195
+ /** Invalidate outstanding previews when the server plugin unloads. */
196
+ dispose() {
197
+ this.pending.clear();
198
+ }
199
+ }
@@ -0,0 +1,90 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { extname, isAbsolute, join } from "node:path";
4
+ import { text } from "./catalog.mjs";
5
+
6
+ const missing = "missing description";
7
+
8
+ /** Read only bounded regular files; never execute plugin code or follow a file symlink.
9
+ * @param {string} file Catalog-selected local file.
10
+ * @param {number} limit Maximum bytes to read.
11
+ * @param {boolean} prefixOnly Allow reading just a bounded header from a larger source.
12
+ */
13
+ async function readMetadata(file, limit, prefixOnly = false) {
14
+ const handle = await open(
15
+ file,
16
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
17
+ );
18
+ try {
19
+ const stat = await handle.stat();
20
+ if (!stat.isFile() || (!prefixOnly && stat.size > limit)) return "";
21
+ const buffer = Buffer.alloc(limit);
22
+ const { bytesRead } = await handle.read(buffer, 0, limit, 0);
23
+ return buffer.subarray(0, bytesRead).toString("utf8");
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ /** Read an explicit description tag from the leading JSDoc block, not tool metadata.
30
+ * @param {string} source Bounded file prefix; never evaluated.
31
+ */
32
+ function headerDescription(source) {
33
+ const header = source.match(
34
+ /^\uFEFF?(?:#![^\n]*\n)?\s*\/\*\*([\s\S]*?)\*\//,
35
+ )?.[1];
36
+ if (!header) return "";
37
+ const lines = header
38
+ .split(/\r?\n/)
39
+ .map((line) => line.replace(/^\s*\*?\s?/, "").trim());
40
+ const start = lines.findIndex((line) => /^@description(?:\s|$)/.test(line));
41
+ if (start < 0) return "";
42
+ const description = [lines[start].replace(/^@description\s*/, "")];
43
+ for (const line of lines.slice(start + 1)) {
44
+ if (line.startsWith("@")) break;
45
+ description.push(line);
46
+ }
47
+ return description.filter(Boolean).join(" ");
48
+ }
49
+
50
+ /**
51
+ * Read a description from the catalog's exact local plugin source. Directories
52
+ * use package.json; single-file plugins use a leading JSDoc @description tag.
53
+ * No ancestor lookup, registry requests, imports, or ID-to-description table.
54
+ * @param {{type?:string,path?:string}|undefined} source Server-owned source metadata.
55
+ * @returns {Promise<string>} Sanitized description or the literal fallback.
56
+ */
57
+ export async function pluginDescription(source) {
58
+ if (
59
+ source?.type !== "local" ||
60
+ typeof source.path !== "string" ||
61
+ !isAbsolute(source.path)
62
+ )
63
+ return missing;
64
+ try {
65
+ const stat = await lstat(source.path);
66
+ let description;
67
+ if (stat.isDirectory()) {
68
+ const manifest = await readMetadata(
69
+ join(source.path, "package.json"),
70
+ 64 * 1024,
71
+ );
72
+ description = JSON.parse(manifest).description;
73
+ } else if (
74
+ stat.isFile() &&
75
+ [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
76
+ extname(source.path),
77
+ )
78
+ ) {
79
+ description = headerDescription(
80
+ await readMetadata(source.path, 16 * 1024, true),
81
+ );
82
+ }
83
+ return typeof description === "string"
84
+ ? text(description.trim()).trim() || missing
85
+ : missing;
86
+ } catch {
87
+ // Missing/unreadable metadata must not break inventory or disclose local paths.
88
+ return missing;
89
+ }
90
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { Rpc } from "@opencode/plugin/rpc";
2
+ import { ID } from "./config.mjs";
3
+
4
+ const rejected = {
5
+ rejected: { type: "object", properties: {}, additionalProperties: false },
6
+ } as const;
7
+ /** Authenticated server RPC only. No agent tool or arbitrary-path write endpoint. */
8
+ export const Lookout = Rpc.define({
9
+ id: ID,
10
+ events: {},
11
+ methods: {
12
+ snapshot: {
13
+ input: { type: "object", properties: {}, additionalProperties: false },
14
+ output: { type: "object" },
15
+ errors: rejected,
16
+ },
17
+ preview: {
18
+ input: {
19
+ type: "object",
20
+ additionalProperties: false,
21
+ properties: {
22
+ kind: { enum: ["skill", "mcp", "plugin"] },
23
+ id: { type: "string", maxLength: 200 },
24
+ enabled: { type: "boolean" },
25
+ },
26
+ required: ["kind", "id", "enabled"],
27
+ },
28
+ output: {
29
+ type: "object",
30
+ properties: { token: { type: "string" }, summary: { type: "string" } },
31
+ required: ["token", "summary"],
32
+ },
33
+ errors: rejected,
34
+ },
35
+ apply: {
36
+ input: {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ properties: { token: { type: "string", maxLength: 100 } },
40
+ required: ["token"],
41
+ },
42
+ output: { type: "object" },
43
+ errors: rejected,
44
+ },
45
+ },
46
+ });
package/src/server.ts ADDED
@@ -0,0 +1,159 @@
1
+ /** @description A native-style resource sidebar with reversible local controls. */
2
+ import { Plugin } from "@opencode/plugin";
3
+ import type { PluginInfo } from "@opencode/client";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { access } from "node:fs/promises";
7
+ import { Lookout } from "./rpc";
8
+ import { ID, owned, parseConfig } from "./config.mjs";
9
+ import { Controller, readConfig } from "./controller.mjs";
10
+ import { KINDS, normalize, mergeOwned } from "./catalog.mjs";
11
+ import { pluginDescription } from "./plugin-description.mjs";
12
+ import { supportsPolicyAPI } from "./compatibility.mjs";
13
+ import type { ChangeRequest, Row, Snapshot } from "./types";
14
+
15
+ /** Never return filesystem errors with paths, or provider errors with payloads. */
16
+ function safeError(error: unknown): string {
17
+ if (error instanceof Error && !("code" in error))
18
+ return error.message.slice(0, 300);
19
+ return "Cannot access the global configuration safely. Check file permissions and run the external status command.";
20
+ }
21
+
22
+ export default Plugin.define({
23
+ id: ID,
24
+ async setup(ctx) {
25
+ const home = homedir();
26
+ const configDir = join(
27
+ process.env.XDG_CONFIG_HOME || join(home, ".config"),
28
+ "opencode",
29
+ );
30
+ const candidates = await Promise.all(
31
+ ["opencode.jsonc", "opencode.json"].map(async (name) => {
32
+ const file = join(configDir, name);
33
+ return await access(file).then(
34
+ () => file,
35
+ () => null,
36
+ );
37
+ }),
38
+ );
39
+ const files = candidates.filter((p): p is string => p !== null);
40
+ const file = files[0] ?? join(configDir, "opencode.jsonc");
41
+ let disposed = false;
42
+ const cache = new Map<string, Row[]>();
43
+
44
+ /** Independent collection failures retain stale rows, but never permit edits. */
45
+ async function snapshot(): Promise<Snapshot> {
46
+ const errors: string[] = [];
47
+ if (!supportsPolicyAPI(ctx.app.version))
48
+ errors.push(
49
+ "Resource controls require OpenCode 2.2.3 or later (current policy API). This host is read-only.",
50
+ );
51
+ let config = {},
52
+ restrictions: { kind: string; id: string }[] = [];
53
+ try {
54
+ if (files.length !== 1)
55
+ throw new Error(
56
+ "Use exactly one global opencode.json or opencode.jsonc to enable controls.",
57
+ );
58
+ const { text } = await readConfig(file);
59
+ config = parseConfig(text);
60
+ restrictions = owned(text);
61
+ } catch (e) {
62
+ errors.push(safeError(e));
63
+ }
64
+ const scope = { configDir, home, config };
65
+ const sources = [
66
+ ctx.agent,
67
+ ctx.skill,
68
+ ctx.plugin,
69
+ ctx.mcp,
70
+ ctx.command,
71
+ ctx.provider,
72
+ ctx.model,
73
+ ];
74
+ const results = await Promise.allSettled(
75
+ sources.map((source) => source.list()),
76
+ );
77
+ const rows: Row[] = [];
78
+ for (const [index, result] of results.entries()) {
79
+ const kind = KINDS[index];
80
+ if (result.status === "fulfilled") {
81
+ const batch = normalize(kind, result.value.data, scope) as Row[];
82
+ if (kind === "plugin") {
83
+ await Promise.all(
84
+ batch.map(async (row, index) => {
85
+ row.description = await pluginDescription(
86
+ (result.value.data[index] as PluginInfo).source,
87
+ );
88
+ }),
89
+ );
90
+ }
91
+ if (!disposed) cache.set(kind, batch);
92
+ rows.push(...batch);
93
+ } else {
94
+ errors.push(
95
+ `${kind} inventory unavailable; showing last snapshot, if any.`,
96
+ );
97
+ rows.push(
98
+ ...(cache.get(kind) ?? []).map((r) => ({
99
+ ...r,
100
+ status: "stale",
101
+ manageable: false,
102
+ })),
103
+ );
104
+ }
105
+ }
106
+ const merged = mergeOwned(rows, restrictions) as Row[];
107
+ if (errors.length)
108
+ merged.forEach((r) => {
109
+ r.manageable = false;
110
+ r.reason = "Refresh successfully before changing configuration.";
111
+ });
112
+ return {
113
+ rows: merged,
114
+ errors,
115
+ location: ctx.location.directory,
116
+ refreshedAt: new Date().toISOString(),
117
+ };
118
+ }
119
+ const controller = new Controller(file, async () => {
120
+ const result = await snapshot();
121
+ if (result.errors.length)
122
+ throw new Error(
123
+ "Inventory incomplete; configuration controls are read-only.",
124
+ );
125
+ return result.rows;
126
+ });
127
+ const registration = await ctx.rpc.register(Lookout, {
128
+ snapshot: async () => snapshot(),
129
+ preview: async (input, call) => {
130
+ try {
131
+ if (
132
+ disposed ||
133
+ files.length !== 1 ||
134
+ !supportsPolicyAPI(ctx.app.version)
135
+ )
136
+ throw new Error("Configuration controller is unavailable.");
137
+ return await controller.preview(input as ChangeRequest);
138
+ } catch (e) {
139
+ return call.error("rejected", safeError(e), {});
140
+ }
141
+ },
142
+ apply: async (input, call) => {
143
+ try {
144
+ if (disposed)
145
+ throw new Error("Configuration controller was unloaded; refresh.");
146
+ return await controller.apply((input as { token: string }).token);
147
+ } catch (e) {
148
+ return call.error("rejected", safeError(e), {});
149
+ }
150
+ },
151
+ });
152
+ return async () => {
153
+ disposed = true;
154
+ controller.dispose();
155
+ cache.clear();
156
+ await registration.dispose();
157
+ };
158
+ },
159
+ });