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,278 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ import { ID, validateID } from "./config.mjs";
3
+
4
+ /** Stable RPC categories, including MCP for compatibility and restriction recovery. */
5
+ export const KINDS = [
6
+ "agent",
7
+ "skill",
8
+ "plugin",
9
+ "mcp",
10
+ "command",
11
+ "provider",
12
+ "model",
13
+ ];
14
+ /** Leave MCP browsing to the native sidebar and /mcps dialog. */
15
+ export const BROWSE_KINDS = KINDS.filter((kind) => kind !== "mcp");
16
+
17
+ /**
18
+ * Limit all Lookout views, including search and saved pins, to its categories.
19
+ * @param {Array<import('./types').Row>} rows Unmodified server snapshot.
20
+ * @returns {Array<import('./types').Row>} Rows for the sidebar and browser.
21
+ */
22
+ export function displayRows(rows) {
23
+ return rows.filter((row) => BROWSE_KINDS.includes(row.kind));
24
+ }
25
+
26
+ /**
27
+ * Select native semantic colors without treating catalog availability as health.
28
+ * @param {{status:string}} row Normalized catalog status.
29
+ * @param {boolean} stale Whether the current snapshot is incomplete or unavailable.
30
+ * @returns {'success'|'warning'|'error'|'muted'} Theme role for the status dot.
31
+ */
32
+ export function statusTone(row, stale = false) {
33
+ if (stale) return "warning";
34
+ if (row.status === "failed") return "error";
35
+ if (["active", "available", "connected"].includes(row.status))
36
+ return "success";
37
+ if (
38
+ [
39
+ "unknown",
40
+ "stale",
41
+ "pending",
42
+ "needs_auth",
43
+ "restricted; still listed",
44
+ ].includes(row.status)
45
+ )
46
+ return "warning";
47
+ return "muted";
48
+ }
49
+ /**
50
+ * Remove terminal control characters and bound untrusted display strings.
51
+ * @param {unknown} value Untrusted display value.
52
+ * @param {number} limit Maximum number of UTF-16 code units to display.
53
+ * @returns {string} Plain text with no C0/C1 control characters.
54
+ */
55
+ export function text(value, limit = 400) {
56
+ return (
57
+ String(value ?? "")
58
+ // ANSI and C0/C1 removal is intentional security filtering, not a regex typo.
59
+ // eslint-disable-next-line no-control-regex
60
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
61
+ // eslint-disable-next-line no-control-regex
62
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
63
+ .slice(0, limit)
64
+ );
65
+ }
66
+ /** @param {string | undefined} file Catalog path. @param {string} root Trusted source root. */
67
+ function inside(file, root) {
68
+ if (!file || !isAbsolute(file) || !isAbsolute(root)) return false;
69
+ const r = relative(root, file);
70
+ return !r.startsWith("..") && !isAbsolute(r);
71
+ }
72
+ /** @param {string} id Catalog ID, validated before use in a policy. */
73
+ function exact(id) {
74
+ try {
75
+ validateID(id);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ /**
82
+ * Produce a display allowlist, never a copy of an API response. In particular,
83
+ * omit prompts, skill bodies, credentials, request headers, and MCP commands.
84
+ * Management is limited to resources explicitly sourced from this user's config.
85
+ * @param {string} kind Catalog category from KINDS.
86
+ * @param {import('./types').CatalogEntry[]} entries Minimal public V2 record projection.
87
+ * @param {{configDir:string,home:string,config?:Record<string, unknown>}} scope Trusted local source boundaries.
88
+ * @returns {Array<import('./types').Row>} Sanitized display and management metadata.
89
+ */
90
+ export function normalize(kind, entries, { configDir, home, config = {} }) {
91
+ const skills = config.skills;
92
+ const paths = Array.isArray(skills)
93
+ ? skills
94
+ : skills &&
95
+ typeof skills === "object" &&
96
+ "paths" in skills &&
97
+ Array.isArray(skills.paths)
98
+ ? skills.paths
99
+ : [];
100
+ const roots = [
101
+ resolve(configDir, "skills"),
102
+ resolve(home, ".agents/skills"),
103
+ resolve(home, ".claude/skills"),
104
+ ...paths
105
+ .filter(
106
+ (/** @type {string} */ p) =>
107
+ typeof p === "string" && (isAbsolute(p) || p.startsWith("~/")),
108
+ )
109
+ .map((/** @type {string} */ p) =>
110
+ p.startsWith("~/") ? resolve(home, p.slice(2)) : p,
111
+ ),
112
+ ];
113
+ const mcp = config.mcp && typeof config.mcp === "object" ? config.mcp : {};
114
+ const localMcp =
115
+ "servers" in mcp && mcp.servers && typeof mcp.servers === "object"
116
+ ? mcp.servers
117
+ : mcp;
118
+ return entries.map((item, index) => {
119
+ const id =
120
+ kind === "model"
121
+ ? `${item.providerID}/${item.id}`
122
+ : (item.id ?? item.name ?? `unidentified-${index}`);
123
+ /** @type {import('./types').Row} */
124
+ const row = {
125
+ kind,
126
+ id,
127
+ label: text(item.name ?? item.title ?? id),
128
+ description: text(item.description),
129
+ status: "available",
130
+ source: "Server catalog",
131
+ manageable: false,
132
+ owned: false,
133
+ reason: "Read-only. Use native OpenCode controls.",
134
+ details: [],
135
+ };
136
+ if (kind === "agent") {
137
+ row.status = item.hidden ? "hidden" : "available";
138
+ row.details = [
139
+ `Mode: ${text(item.mode)}`,
140
+ `Model: ${item.model ? text(`${item.model.providerID}/${item.model.id}#${item.model.variant ?? "default"}`) : "inherited"}`,
141
+ ];
142
+ row.reason =
143
+ "Agents have no enabled flag. Use /agents to select an agent.";
144
+ } else if (kind === "skill") {
145
+ row.source = roots.some((r) => inside(item.path, r))
146
+ ? "Local skill source"
147
+ : "Other or managed source";
148
+ row.manageable = row.source === "Local skill source" && exact(id);
149
+ row.details = [
150
+ `Auto-invocation: ${item.autoinvoke === false ? "off" : "on when described"}`,
151
+ ];
152
+ } else if (kind === "plugin") {
153
+ // The server replaces this only with metadata read from the plugin's files.
154
+ row.description = "missing description";
155
+ row.source = text(item.source?.type ?? "unknown");
156
+ row.status = item.state?.status ?? "unknown";
157
+ row.details = [
158
+ `Parts: ${
159
+ Object.keys(item.features ?? {})
160
+ .filter((k) => item.features?.[k])
161
+ .join(", ") || "unknown"
162
+ }`,
163
+ ];
164
+ row.manageable =
165
+ item.source?.type === "local" &&
166
+ inside(item.source.path, configDir) &&
167
+ exact(id) &&
168
+ id !== ID &&
169
+ !id.startsWith("opencode.");
170
+ if (item.source?.type === "builtin")
171
+ row.reason = "Built-in plugins are protected in this extension.";
172
+ } else if (kind === "mcp") {
173
+ row.status =
174
+ typeof item.status === "object" ? item.status.status : "unknown";
175
+ row.source =
176
+ Object.hasOwn(localMcp, id) && !item.integrationID
177
+ ? "Global configuration"
178
+ : "Other or managed source";
179
+ row.manageable =
180
+ row.source === "Global configuration" &&
181
+ exact(id) &&
182
+ row.status !== "disabled";
183
+ } else if (kind === "provider") {
184
+ row.status = item.activation ?? "available";
185
+ row.details = [`Integration: ${text(item.integrationID ?? "unknown")}`];
186
+ } else if (kind === "model") {
187
+ row.status = item.enabled === false ? "disabled" : "available";
188
+ row.details = [
189
+ `Variants: ${(item.variants ?? []).map((/** @type {{id:string}} */ v) => text(v.id)).join(", ") || "default"}`,
190
+ `Context: ${item.limit?.context ?? "unknown"}; output: ${item.limit?.output ?? "unknown"}`,
191
+ `Tools: ${item.capabilities?.tools ? "yes" : "no"}`,
192
+ ];
193
+ }
194
+ if (row.manageable)
195
+ row.reason =
196
+ "Exact-ID global restriction; confirmation required. Other sessions can be affected.";
197
+ return row;
198
+ });
199
+ }
200
+ /**
201
+ * Restore disabled rows that policy filtering removes from the live catalog.
202
+ * @param {Array<import('./types').Row>} rows Current normalized inventory.
203
+ * @param {{kind:string,id:string}[]} restrictions Validated owned config entries.
204
+ * @returns {Array<import('./types').Row>} Inventory including re-enable controls.
205
+ */
206
+ export function mergeOwned(rows, restrictions) {
207
+ const result = rows.map((r) => ({ ...r, details: [...r.details] }));
208
+ for (const { kind, id } of restrictions) {
209
+ let row = result.find((r) => r.kind === kind && r.id === id);
210
+ if (!row) {
211
+ row = {
212
+ kind,
213
+ id,
214
+ label: text(id),
215
+ description: kind === "plugin" ? "missing description" : "",
216
+ status: "not listed",
217
+ source: "Lookout restriction",
218
+ details: [],
219
+ manageable: true,
220
+ owned: true,
221
+ reason: "",
222
+ };
223
+ result.push(row);
224
+ }
225
+ row.owned = true;
226
+ row.manageable = true;
227
+ row.status = ["not listed", "disabled"].includes(row.status)
228
+ ? "disabled (Lookout)"
229
+ : "restricted; still listed";
230
+ row.reason =
231
+ "Enable removes only the Lookout restriction. Other rules or unavailable sources may still block this item.";
232
+ }
233
+ return result;
234
+ }
235
+ /**
236
+ * Filter by kind, state, and literal search terms; never evaluate a user regex.
237
+ * @param {Array<import('./types').Row>} rows Normalized inventory.
238
+ * @param {{kind?:string,state?:string,query?:string}} options Composable filters.
239
+ * @returns {Array<import('./types').Row>} Stable label/ID order, owned entries first.
240
+ */
241
+ export function filterRows(
242
+ rows,
243
+ { kind = "all", state = "all", query = "" } = {},
244
+ ) {
245
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
246
+ return rows
247
+ .filter(
248
+ (r) =>
249
+ (kind === "all" || r.kind === kind) &&
250
+ (state === "all" ||
251
+ (state === "managed"
252
+ ? r.manageable
253
+ : state === "disabled"
254
+ ? r.owned || r.status.includes("disabled")
255
+ : state === "errors"
256
+ ? ["failed", "unknown", "stale"].includes(r.status)
257
+ : state === "local"
258
+ ? [
259
+ "local",
260
+ "Local skill source",
261
+ "Global configuration",
262
+ ].includes(r.source)
263
+ : false)) &&
264
+ terms.every((term) =>
265
+ [r.id, r.label, r.description, r.source, r.status, ...r.details]
266
+ .join(" ")
267
+ .toLowerCase()
268
+ .includes(term),
269
+ ),
270
+ )
271
+ .sort(
272
+ (a, b) =>
273
+ Number(b.owned) - Number(a.owned) ||
274
+ Number(b.manageable) - Number(a.manageable) ||
275
+ a.label.localeCompare(b.label) ||
276
+ a.id.localeCompare(b.id),
277
+ );
278
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Require the public skill.use/mcp.use policy API introduced in OpenCode 2.2.3.
3
+ * Unknown version strings fail closed. Prereleases qualify only above that floor;
4
+ * release validation still requires their actual integration tests to pass.
5
+ * @param {string} version Host-reported CLI/server version, not user configuration.
6
+ * @returns {boolean} Whether resource controls may use the current policy API.
7
+ */
8
+ export function supportsPolicyAPI(version) {
9
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version);
10
+ if (!match) return false;
11
+ const [major, minor, patch] = match.slice(1).map(Number);
12
+ return (
13
+ major === 2 &&
14
+ (minor > 2 ||
15
+ (minor === 2 && (patch > 3 || (patch === 3 && !version.includes("-")))))
16
+ );
17
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,250 @@
1
+ import {
2
+ parseTree,
3
+ getNodeValue,
4
+ findNodeAtLocation,
5
+ modify,
6
+ applyEdits,
7
+ } from "jsonc-parser";
8
+
9
+ /** Shared server/TUI plugin ID, also used by the offline unload command. */
10
+ export const ID = "opencode-lookout";
11
+ /** @param {string} key Owned array name. */
12
+ const begin = (key) => `/* lookout:begin:${key} */`;
13
+ /** @param {string} key Owned array name. */
14
+ const end = (key) => `/* lookout:end:${key} */`;
15
+ /**
16
+ * Reject policy wildcards and control characters instead of escaping them.
17
+ * @param {string} id Exact catalog resource ID.
18
+ * @throws {Error} If the ID cannot safely be used in an exact policy rule.
19
+ */
20
+ export function validateID(id) {
21
+ if (
22
+ typeof id !== "string" ||
23
+ !/^[a-zA-Z0-9_][a-zA-Z0-9_.:/@-]{0,199}$/.test(id)
24
+ )
25
+ throw new Error("Only exact resource IDs without wildcards are supported.");
26
+ }
27
+ /** Validate syntax and duplicate keys before any transformation.
28
+ * @param {string} text Candidate JSONC.
29
+ */
30
+ function tree(text) {
31
+ /** @type {import('jsonc-parser').ParseError[]} */
32
+ const errors = [];
33
+ const root = parseTree(text, errors, { allowTrailingComma: true });
34
+ if (errors.length || root?.type !== "object")
35
+ throw new Error("Invalid JSONC configuration; nothing changed.");
36
+ /** @param {import('jsonc-parser').Node} node Parsed syntax node. */
37
+ function visit(node) {
38
+ if (node.type === "object") {
39
+ const keys = (node.children ?? []).map((p) => p.children?.[0].value);
40
+ if (new Set(keys).size !== keys.length)
41
+ throw new Error("Duplicate configuration keys; nothing changed.");
42
+ }
43
+ node.children?.forEach(visit);
44
+ }
45
+ visit(root);
46
+ for (const key of ["plugins", "policies"]) {
47
+ const node = findNodeAtLocation(root, [key]);
48
+ if (node && node.type !== "array")
49
+ throw new Error(`${key} must be an array; nothing changed.`);
50
+ }
51
+ return root;
52
+ }
53
+ /** @param {string} text JSONC source. @returns {Record<string, unknown>} Syntax-validated configuration; narrow individual values before use. */
54
+ export const parseConfig = (text) => getNodeValue(tree(text));
55
+
56
+ /** Parse only our canonical tail block. Hand-edited blocks must fail closed.
57
+ * @param {string} text Validated JSONC.
58
+ * @param {string} key Owned array name.
59
+ */
60
+ function block(text, key) {
61
+ const root = tree(text),
62
+ start = text.indexOf(begin(key)),
63
+ stop = text.indexOf(end(key));
64
+ if (start === -1 && stop === -1) return { values: [], start: -1, stop: -1 };
65
+ if (
66
+ start < 0 ||
67
+ stop < start ||
68
+ text.indexOf(begin(key), start + 1) !== -1 ||
69
+ text.indexOf(end(key), stop + 1) !== -1
70
+ )
71
+ throw new Error("Damaged Lookout markers; nothing changed.");
72
+ const array = findNodeAtLocation(root, [key]);
73
+ if (
74
+ !array ||
75
+ start <= array.offset ||
76
+ stop >= array.offset + array.length ||
77
+ text.slice(stop + end(key).length, array.offset + array.length - 1).trim()
78
+ )
79
+ throw new Error("Moved Lookout block; nothing changed.");
80
+ const values = (array.children ?? [])
81
+ .filter((n) => n.offset > start && n.offset < stop)
82
+ .map(getNodeValue);
83
+ for (const v of values) {
84
+ if (key === "plugins") {
85
+ if (typeof v !== "string" || !v.startsWith("-"))
86
+ throw new Error("Modified owned plugin entry.");
87
+ validateID(v.slice(1));
88
+ } else {
89
+ if (
90
+ !["skill.use", "mcp.use"].includes(v.action) ||
91
+ v.effect !== "deny" ||
92
+ Object.keys(v).sort().join() !== "action,effect,resource"
93
+ )
94
+ throw new Error("Modified owned policy entry.");
95
+ validateID(v.resource);
96
+ }
97
+ }
98
+ // Only whitespace, our comma and owned values are allowed inside the block.
99
+ const fragment = text
100
+ .slice(start + begin(key).length, stop)
101
+ .trim()
102
+ .replace(/^,/, "")
103
+ .trim();
104
+ if (fragment !== values.map((v) => JSON.stringify(v)).join(", "))
105
+ throw new Error("Edited Lookout block; nothing changed.");
106
+ return { values, start, stop: stop + end(key).length };
107
+ }
108
+ /**
109
+ * Recover exact-ID restrictions without needing the filtered server catalog.
110
+ * @param {string} text Global JSONC configuration.
111
+ * @returns {{kind: string, id: string}[]} Restrictions owned by this extension.
112
+ */
113
+ export function owned(text) {
114
+ return [
115
+ ...block(text, "policies").values.map((v) => ({
116
+ kind: v.action === "skill.use" ? "skill" : "mcp",
117
+ id: v.resource,
118
+ })),
119
+ ...block(text, "plugins").values.map((v) => ({
120
+ kind: "plugin",
121
+ id: v.slice(1),
122
+ })),
123
+ ];
124
+ }
125
+ /** Replace only marked bytes; leave external entries and comments intact.
126
+ * @param {string} text Validated JSONC.
127
+ * @param {string} key Owned array name.
128
+ * @param {Array<string | {action:string,resource:string,effect:string}>} values Canonical owned entries.
129
+ */
130
+ function replaceBlock(text, key, values) {
131
+ const current = block(text, key);
132
+ if (current.start !== -1)
133
+ text = text.slice(0, current.start) + text.slice(current.stop);
134
+ if (!values.length) {
135
+ tree(text);
136
+ return text;
137
+ }
138
+ if (!findNodeAtLocation(tree(text), [key]))
139
+ text = applyEdits(
140
+ text,
141
+ modify(text, [key], [], {
142
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
143
+ }),
144
+ );
145
+ const array = findNodeAtLocation(tree(text), [key]);
146
+ if (!array?.children)
147
+ throw new Error("Missing configuration array; nothing changed.");
148
+ const offset = array.offset + array.length - 1;
149
+ const last = array.children.at(-1);
150
+ // Check trailing comma via scanner-free parsing of the between-nodes region.
151
+ const tail = last
152
+ ? text
153
+ .slice(last.offset + last.length, offset)
154
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "")
155
+ : "";
156
+ const comma = last && !tail.includes(",") ? ", " : "";
157
+ const content = `${begin(key)}${comma}${values.map((v) => JSON.stringify(v)).join(", ")}${end(key)}`;
158
+ const next = text.slice(0, offset) + content + text.slice(offset);
159
+ tree(next);
160
+ return next;
161
+ }
162
+ /**
163
+ * Compute a reversible restriction change; this function performs no I/O.
164
+ * @param {string} text Current global JSONC configuration.
165
+ * @param {string} kind One of skill, mcp, or plugin.
166
+ * @param {string} id Exact resource ID from a validated inventory.
167
+ * @param {boolean} enabled True removes our denial; false adds one.
168
+ * @returns {string} Valid JSONC with only the owned array block changed.
169
+ * @throws {Error} For unsupported controls, protected IDs, or edited blocks.
170
+ */
171
+ export function change(text, kind, id, enabled) {
172
+ validateID(id);
173
+ if (
174
+ !["skill", "mcp", "plugin"].includes(kind) ||
175
+ typeof enabled !== "boolean"
176
+ )
177
+ throw new Error("Unsupported control.");
178
+ if (kind === "plugin" && (id === ID || id.startsWith("opencode.")))
179
+ throw new Error(
180
+ "Protected plugin; use the external lifecycle command for Lookout.",
181
+ );
182
+ const key = kind === "plugin" ? "plugins" : "policies";
183
+ const all = owned(text);
184
+ const exists = all.some((v) => v.kind === kind && v.id === id);
185
+ if (enabled && !exists)
186
+ throw new Error(
187
+ "No Lookout restriction to remove. Existing external rules remain authoritative.",
188
+ );
189
+ const next = all.filter((v) => !(v.kind === kind && v.id === id));
190
+ if (!enabled) next.push({ kind, id });
191
+ next.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
192
+ const values =
193
+ key === "plugins"
194
+ ? next.filter((v) => v.kind === "plugin").map((v) => `-${v.id}`)
195
+ : next
196
+ .filter((v) => v.kind !== "plugin")
197
+ .map((v) => ({
198
+ action: `${v.kind}.use`,
199
+ resource: v.id,
200
+ effect: "deny",
201
+ }));
202
+ return replaceBlock(text, key, values);
203
+ }
204
+
205
+ /**
206
+ * Update the separate lifecycle entry without touching resource restrictions.
207
+ * @param {string} text Existing global JSONC configuration.
208
+ * @param {string} packagePath Trusted path derived from the installed CLI file.
209
+ * @param {boolean} enable Whether to load this package.
210
+ * @returns {string} Updated configuration; the caller owns atomic persistence.
211
+ */
212
+ export function lifecycle(text, packagePath, enable) {
213
+ const marker = "/* lookout:lifecycle */";
214
+ tree(text);
215
+ if (!findNodeAtLocation(tree(text), ["plugins"]))
216
+ text = applyEdits(
217
+ text,
218
+ modify(text, ["plugins"], [], {
219
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
220
+ }),
221
+ );
222
+ const array = findNodeAtLocation(tree(text), ["plugins"]);
223
+ if (!array?.children)
224
+ throw new Error("Missing plugin array; nothing changed.");
225
+ const desired = enable ? packagePath : `-${ID}`;
226
+ const index = text.indexOf(marker);
227
+ if (index !== -1) {
228
+ if (text.indexOf(marker, index + 1) !== -1)
229
+ throw new Error("Duplicate lifecycle marker.");
230
+ const node = array.children.find(
231
+ (n) =>
232
+ n.offset + n.length <= index &&
233
+ !text.slice(n.offset + n.length, index).trim(),
234
+ );
235
+ if (!node || ![packagePath, `-${ID}`].includes(node.value))
236
+ throw new Error("Edited lifecycle entry; nothing changed.");
237
+ return (
238
+ text.slice(0, node.offset) +
239
+ JSON.stringify(desired) +
240
+ text.slice(node.offset + node.length)
241
+ );
242
+ }
243
+ const pos = array.offset + 1;
244
+ // Inserting at the front leaves the resource-control block at the end untouched.
245
+ return (
246
+ text.slice(0, pos) +
247
+ `${JSON.stringify(desired)} ${marker}${array.children.length ? ", " : ""}` +
248
+ text.slice(pos)
249
+ );
250
+ }