@max10240/opencode-plugin-sensitive-protection 0.0.0-stage → 1.0.14
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/README.md +61 -2
- package/dist/index.js +1 -0
- package/dist/src/index.js +171 -0
- package/dist/src/options.js +39 -0
- package/dist/src/registry.js +175 -0
- package/dist/src/tui.js +57 -0
- package/dist/tui.js +7 -0
- package/package.json +51 -4
package/README.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @max10240/opencode-plugin-sensitive-protection
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
登记保护文件,在文件读取或工具输出进入模型上下文前阻断或脱敏。可独立使用,不依赖加解密插件。
|
|
4
|
+
|
|
5
|
+
## 能力
|
|
6
|
+
|
|
7
|
+
- `register_sensitive_file`:登记文件,读取内容到内存用于匹配,不输出文件内容;
|
|
8
|
+
- `/protect-sensitive-file <path>`:server 命令登记;
|
|
9
|
+
- `/protect-sensitive-file-ui` / keymap:TUI 弹出路径输入并登记;
|
|
10
|
+
- `read` 精确路径命中保护文件时抛出 `Tool.Error`;
|
|
11
|
+
- 所有 tool 输出命中保护内容时,将敏感片段替换为 `[REDACTED:PROTECTED]`;
|
|
12
|
+
- 模型请求上下文和 HTTP 请求体做最终脱敏;
|
|
13
|
+
- 按 `kid`/路径持久化登记表。
|
|
14
|
+
|
|
15
|
+
不拦截 `grep`、`shell`、`edit`、`write` 的输入;它们在输出阶段脱敏。
|
|
16
|
+
|
|
17
|
+
## 配置
|
|
18
|
+
|
|
19
|
+
```jsonc
|
|
20
|
+
{
|
|
21
|
+
"sensitiveProtection": {
|
|
22
|
+
"enabled": true,
|
|
23
|
+
"blockReads": true,
|
|
24
|
+
"redactContext": true,
|
|
25
|
+
"redactHttp": true,
|
|
26
|
+
"maxContentBytes": 8388608
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `enabled` 默认 `true`;
|
|
32
|
+
- `blockReads` 默认 `true`;
|
|
33
|
+
- `maxContentBytes` 默认 8 MiB,硬上限 64 MiB;
|
|
34
|
+
- 内容匹配支持整段、逐行、JSON 转义、Base64/Base64URL、Hex。
|
|
35
|
+
|
|
36
|
+
## 逃生舱
|
|
37
|
+
|
|
38
|
+
当前项目可放置:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
.opencode/sensitive-protection.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{ "enabled": false }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
只关闭当前 project/location 的保护,不影响加解密插件。
|
|
49
|
+
|
|
50
|
+
## 数据
|
|
51
|
+
|
|
52
|
+
登记表保存到:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
<XDG_DATA_HOME 或 ~/.local/share>/opencode/sensitive-protection/registry.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
只保存路径和 metadata,不保存文件内容。
|
|
59
|
+
|
|
60
|
+
## 边界
|
|
61
|
+
|
|
62
|
+
插件 hook 不能保证所有编码、压缩、拆分、MCP/子 Agent 内部操作都被识别;强隔离需要 OS 沙箱或独立用户。
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, setupSensitiveProtection, REDACTION_TOKEN, REGISTER_TOOL_NAME } from "./src/index.js";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { Error as ToolError } from "@opencode/plugin/promise/tool";
|
|
3
|
+
import { parseProtectionOptions } from "./options.js";
|
|
4
|
+
import { ProtectionRegistry, resolveProtectedPath } from "./registry.js";
|
|
5
|
+
export { REDACTION_TOKEN } from "./registry.js";
|
|
6
|
+
export const REGISTER_TOOL_NAME = "register_sensitive_file";
|
|
7
|
+
export async function setupSensitiveProtection(context) {
|
|
8
|
+
let options;
|
|
9
|
+
try {
|
|
10
|
+
options = await parseProtectionOptions(context.options, context.location.directory);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
console.warn(`Sensitive protection disabled: ${error instanceof Error ? error.message : "invalid options"}`);
|
|
14
|
+
return async () => { };
|
|
15
|
+
}
|
|
16
|
+
if (!options.enabled)
|
|
17
|
+
return async () => { };
|
|
18
|
+
const registry = new ProtectionRegistry(options.dataDirectory, options.maxContentBytes);
|
|
19
|
+
await registry.load();
|
|
20
|
+
const registerPath = async (value, input = {}) => {
|
|
21
|
+
const resolved = await resolveProtectedPath(value, context.location.directory);
|
|
22
|
+
const result = await registry.register(resolved, {
|
|
23
|
+
role: input.role ?? "manual",
|
|
24
|
+
keyID: input.keyID,
|
|
25
|
+
label: input.label,
|
|
26
|
+
});
|
|
27
|
+
return { path: resolved, bytes: result.bytes };
|
|
28
|
+
};
|
|
29
|
+
const disposals = [];
|
|
30
|
+
try {
|
|
31
|
+
disposals.push(await context.tool.transform((editor) => {
|
|
32
|
+
editor.add({
|
|
33
|
+
name: REGISTER_TOOL_NAME,
|
|
34
|
+
options: { codemode: false },
|
|
35
|
+
description: "Register an existing file as protected. The file is read locally for redaction; its contents are never output.",
|
|
36
|
+
input: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
file: { type: "string", description: "Existing file path to protect." },
|
|
40
|
+
role: { type: "string", enum: ["encrypt-input", "decrypt-output", "manual"] },
|
|
41
|
+
keyID: { type: "string" },
|
|
42
|
+
label: { type: "string" },
|
|
43
|
+
sessionID: { type: "string" },
|
|
44
|
+
toolCallID: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
required: ["file"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
execute: async (rawInput) => {
|
|
50
|
+
const input = rawInput;
|
|
51
|
+
if (typeof input.file !== "string" || !input.file.trim())
|
|
52
|
+
throw new Error("file is required");
|
|
53
|
+
const role = input.role === "encrypt-input" || input.role === "decrypt-output" ? input.role : "manual";
|
|
54
|
+
const result = await registerPath(input.file, {
|
|
55
|
+
role,
|
|
56
|
+
...(typeof input.keyID === "string" ? { keyID: input.keyID } : {}),
|
|
57
|
+
...(typeof input.label === "string" ? { label: input.label } : {}),
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
content: "Protected file registered.",
|
|
61
|
+
metadata: { registered: true, file: result.path, bytes: result.bytes },
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}));
|
|
66
|
+
if (options.blockReads) {
|
|
67
|
+
disposals.push(await context.tool.hook("execute.before", async (event) => {
|
|
68
|
+
if (event.tool !== "read" || !event.input || typeof event.input !== "object")
|
|
69
|
+
return;
|
|
70
|
+
const value = event.input.path;
|
|
71
|
+
if (typeof value !== "string")
|
|
72
|
+
return;
|
|
73
|
+
const resolved = await resolveProtectedPath(value, context.location.directory).catch(() => undefined);
|
|
74
|
+
if (resolved && registry.has(resolved)) {
|
|
75
|
+
throw new ToolError({ message: "Blocked: this file is protected; use an approved viewer instead." });
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
disposals.push(await context.tool.hook("execute.after", async (event) => {
|
|
80
|
+
if (registry.size === 0)
|
|
81
|
+
return;
|
|
82
|
+
if (event.status === "error") {
|
|
83
|
+
const error = event.error;
|
|
84
|
+
const message = registry.redactValue(error.message);
|
|
85
|
+
const metadata = registry.redactValue(error.metadata);
|
|
86
|
+
if (!message.changed && !metadata.changed)
|
|
87
|
+
return;
|
|
88
|
+
if (message.changed)
|
|
89
|
+
error.message = String(message.value);
|
|
90
|
+
if (metadata.changed)
|
|
91
|
+
error.metadata = metadata.value;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const result = event.result;
|
|
95
|
+
const content = registry.redactValue(result.content);
|
|
96
|
+
const output = registry.redactValue(result.output);
|
|
97
|
+
const metadata = registry.redactValue(result.metadata);
|
|
98
|
+
if (!content.changed && !output.changed && !metadata.changed)
|
|
99
|
+
return;
|
|
100
|
+
result.content = content.value;
|
|
101
|
+
result.output = output.value;
|
|
102
|
+
result.metadata = {
|
|
103
|
+
...(metadata.value && typeof metadata.value === "object" && !Array.isArray(metadata.value) ? metadata.value : {}),
|
|
104
|
+
redacted: true,
|
|
105
|
+
reason: "protected-sensitive-content",
|
|
106
|
+
};
|
|
107
|
+
}));
|
|
108
|
+
if (options.redactContext) {
|
|
109
|
+
disposals.push(await context.session.hook("context", async (event) => {
|
|
110
|
+
if (registry.size === 0)
|
|
111
|
+
return;
|
|
112
|
+
const messages = registry.redactValue(event.messages);
|
|
113
|
+
const system = registry.redactValue(event.system);
|
|
114
|
+
if (messages.changed)
|
|
115
|
+
event.messages = messages.value;
|
|
116
|
+
if (system.changed)
|
|
117
|
+
event.system = system.value;
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
if (options.redactHttp) {
|
|
121
|
+
disposals.push(await context.session.hook("http.request", async (event) => {
|
|
122
|
+
if (registry.size === 0)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
const method = event.request.method.toUpperCase();
|
|
126
|
+
if (method === "GET" || method === "HEAD")
|
|
127
|
+
return;
|
|
128
|
+
const body = await event.request.clone().text();
|
|
129
|
+
const redacted = registry.redact(body);
|
|
130
|
+
if (redacted === body)
|
|
131
|
+
return;
|
|
132
|
+
event.request = new Request(event.request.url, {
|
|
133
|
+
method: event.request.method,
|
|
134
|
+
headers: event.request.headers,
|
|
135
|
+
body: redacted,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Never break the provider request because optional redaction failed.
|
|
140
|
+
}
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
disposals.push(await context.command.transform((editor) => {
|
|
144
|
+
editor.add({
|
|
145
|
+
name: "protect-sensitive-file",
|
|
146
|
+
description: "Register a file as protected from model context.",
|
|
147
|
+
execute: async (invocation) => {
|
|
148
|
+
const text = typeof invocation.prompt.text === "string"
|
|
149
|
+
? invocation.prompt.text.trim()
|
|
150
|
+
: "";
|
|
151
|
+
if (!text)
|
|
152
|
+
throw new Error("A file path is required");
|
|
153
|
+
await registerPath(text);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
await Promise.allSettled(disposals.map((item) => item.dispose()));
|
|
160
|
+
console.warn(`Sensitive protection disabled: ${error instanceof Error ? error.message : "setup failed"}`);
|
|
161
|
+
return async () => { };
|
|
162
|
+
}
|
|
163
|
+
return async () => {
|
|
164
|
+
await Promise.allSettled(disposals.map((item) => item.dispose()));
|
|
165
|
+
await registry.flush();
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export default Plugin.define({
|
|
169
|
+
id: "max10240.sensitive-protection",
|
|
170
|
+
setup: setupSensitiveProtection,
|
|
171
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
export const DEFAULT_MAX_CONTENT_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
export const MAX_CONTENT_BYTES_LIMIT = 64 * 1024 * 1024;
|
|
6
|
+
export function dataDirectory(environment = process.env, home = os.homedir()) {
|
|
7
|
+
const base = environment.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
8
|
+
return path.join(base, "opencode", "sensitive-protection");
|
|
9
|
+
}
|
|
10
|
+
export async function projectEscapeHatch(directory) {
|
|
11
|
+
const file = path.join(directory, ".opencode", "sensitive-protection.json");
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
14
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
15
|
+
return true;
|
|
16
|
+
const value = parsed;
|
|
17
|
+
if (value.enabled === false || value.disabled === true)
|
|
18
|
+
return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function parseProtectionOptions(raw = {}, directory, environment = process.env) {
|
|
26
|
+
const configured = raw.maxContentBytes;
|
|
27
|
+
if (configured !== undefined && (typeof configured !== "number" || !Number.isSafeInteger(configured) || configured < 1 || configured > MAX_CONTENT_BYTES_LIMIT)) {
|
|
28
|
+
throw new Error(`maxContentBytes must be an integer between 1 and ${MAX_CONTENT_BYTES_LIMIT}`);
|
|
29
|
+
}
|
|
30
|
+
const enabled = raw.enabled !== false && (await projectEscapeHatch(directory));
|
|
31
|
+
return {
|
|
32
|
+
enabled,
|
|
33
|
+
blockReads: raw.blockReads !== false,
|
|
34
|
+
redactContext: raw.redactContext !== false,
|
|
35
|
+
redactHttp: raw.redactHttp !== false,
|
|
36
|
+
maxContentBytes: configured === undefined ? DEFAULT_MAX_CONTENT_BYTES : configured,
|
|
37
|
+
dataDirectory: dataDirectory(environment),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
export const REDACTION_TOKEN = "[REDACTED:PROTECTED]";
|
|
6
|
+
export class ProtectionRegistry {
|
|
7
|
+
directory;
|
|
8
|
+
maxContentBytes;
|
|
9
|
+
entries = new Map();
|
|
10
|
+
saveQueue = Promise.resolve();
|
|
11
|
+
constructor(directory, maxContentBytes) {
|
|
12
|
+
this.directory = directory;
|
|
13
|
+
this.maxContentBytes = maxContentBytes;
|
|
14
|
+
}
|
|
15
|
+
get size() {
|
|
16
|
+
return this.entries.size;
|
|
17
|
+
}
|
|
18
|
+
has(value) {
|
|
19
|
+
return this.entries.has(value);
|
|
20
|
+
}
|
|
21
|
+
async load() {
|
|
22
|
+
const file = path.join(this.directory, "registry.json");
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries))
|
|
31
|
+
return;
|
|
32
|
+
for (const raw of parsed.entries) {
|
|
33
|
+
if (!raw || typeof raw !== "object")
|
|
34
|
+
continue;
|
|
35
|
+
const entry = raw;
|
|
36
|
+
if (typeof entry.path !== "string" || typeof entry.role !== "string" || typeof entry.createdAt !== "number")
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
await this.register(entry.path, {
|
|
40
|
+
role: entry.role,
|
|
41
|
+
keyID: entry.keyID,
|
|
42
|
+
label: entry.label,
|
|
43
|
+
createdAt: entry.createdAt,
|
|
44
|
+
persist: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// The file may have been removed since the registry was written.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async register(value, input) {
|
|
53
|
+
const resolved = await realpath(value);
|
|
54
|
+
const info = await lstat(resolved);
|
|
55
|
+
if (info.isSymbolicLink())
|
|
56
|
+
throw new Error("Protected file must not be a symbolic link");
|
|
57
|
+
if (!info.isFile())
|
|
58
|
+
throw new Error("Protected file must be a regular file");
|
|
59
|
+
if (info.size > this.maxContentBytes)
|
|
60
|
+
throw new Error(`Protected file exceeds the ${this.maxContentBytes} byte limit`);
|
|
61
|
+
const content = await readFile(resolved);
|
|
62
|
+
const patterns = buildPatterns(content);
|
|
63
|
+
const entry = {
|
|
64
|
+
path: resolved,
|
|
65
|
+
role: input.role,
|
|
66
|
+
...(input.keyID === undefined ? {} : { keyID: input.keyID }),
|
|
67
|
+
...(input.label === undefined ? {} : { label: input.label }),
|
|
68
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
69
|
+
patterns,
|
|
70
|
+
};
|
|
71
|
+
this.entries.set(resolved, entry);
|
|
72
|
+
if (input.persist !== false)
|
|
73
|
+
void this.queueSave();
|
|
74
|
+
return { path: resolved, bytes: content.length };
|
|
75
|
+
}
|
|
76
|
+
redact(text) {
|
|
77
|
+
let value = text;
|
|
78
|
+
for (const entry of this.entries.values()) {
|
|
79
|
+
for (const pattern of entry.patterns) {
|
|
80
|
+
if (pattern && value.includes(pattern))
|
|
81
|
+
value = value.split(pattern).join(REDACTION_TOKEN);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
redactValue(value) {
|
|
87
|
+
if (this.entries.size === 0)
|
|
88
|
+
return { value, changed: false };
|
|
89
|
+
if (typeof value === "string") {
|
|
90
|
+
const next = this.redact(value);
|
|
91
|
+
return { value: next, changed: next !== value };
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
let changed = false;
|
|
95
|
+
const next = value.map((item) => {
|
|
96
|
+
const result = this.redactValue(item);
|
|
97
|
+
if (result.changed)
|
|
98
|
+
changed = true;
|
|
99
|
+
return result.value;
|
|
100
|
+
});
|
|
101
|
+
return { value: next, changed };
|
|
102
|
+
}
|
|
103
|
+
if (value && typeof value === "object") {
|
|
104
|
+
let changed = false;
|
|
105
|
+
const next = {};
|
|
106
|
+
for (const [key, item] of Object.entries(value)) {
|
|
107
|
+
const result = this.redactValue(item);
|
|
108
|
+
if (result.changed)
|
|
109
|
+
changed = true;
|
|
110
|
+
next[key] = result.value;
|
|
111
|
+
}
|
|
112
|
+
return { value: next, changed };
|
|
113
|
+
}
|
|
114
|
+
return { value, changed: false };
|
|
115
|
+
}
|
|
116
|
+
async flush() {
|
|
117
|
+
await this.queueSave();
|
|
118
|
+
}
|
|
119
|
+
queueSave() {
|
|
120
|
+
this.saveQueue = this.saveQueue.then(() => this.save()).catch(() => { });
|
|
121
|
+
return this.saveQueue;
|
|
122
|
+
}
|
|
123
|
+
async save() {
|
|
124
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
125
|
+
const file = path.join(this.directory, "registry.json");
|
|
126
|
+
const temporary = path.join(this.directory, `.registry.${randomUUID()}.tmp`);
|
|
127
|
+
const entries = [...this.entries.values()].map(({ patterns: _patterns, ...entry }) => entry);
|
|
128
|
+
try {
|
|
129
|
+
await writeFile(temporary, JSON.stringify({ version: 1, entries }, null, 2), { mode: 0o600, flag: "wx" });
|
|
130
|
+
await rename(temporary, file);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await unlink(temporary).catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function buildPatterns(content) {
|
|
138
|
+
const text = content.toString("utf8");
|
|
139
|
+
if (!text)
|
|
140
|
+
return [];
|
|
141
|
+
const patterns = new Set();
|
|
142
|
+
const add = (value) => {
|
|
143
|
+
if (value && value.length >= 8)
|
|
144
|
+
patterns.add(value);
|
|
145
|
+
};
|
|
146
|
+
add(text);
|
|
147
|
+
for (const line of text.split("\n")) {
|
|
148
|
+
add(line.replace(/\r$/, ""));
|
|
149
|
+
}
|
|
150
|
+
const escaped = JSON.stringify(text).slice(1, -1);
|
|
151
|
+
if (escaped !== text) {
|
|
152
|
+
add(escaped);
|
|
153
|
+
for (const line of escaped.split("\\n"))
|
|
154
|
+
add(line);
|
|
155
|
+
}
|
|
156
|
+
if (content.length <= 1024 * 1024) {
|
|
157
|
+
add(content.toString("base64"));
|
|
158
|
+
add(content.toString("base64url"));
|
|
159
|
+
add(content.toString("hex"));
|
|
160
|
+
add(content.toString("hex").toUpperCase());
|
|
161
|
+
}
|
|
162
|
+
return [...patterns].sort((left, right) => right.length - left.length);
|
|
163
|
+
}
|
|
164
|
+
export async function resolveProtectedPath(value, directory) {
|
|
165
|
+
if (typeof value !== "string" || !value.trim())
|
|
166
|
+
throw new Error("file must be a non-empty path");
|
|
167
|
+
const root = path.resolve(os.tmpdir());
|
|
168
|
+
const expanded = value === "~" ? process.env.HOME ?? "" : value.startsWith("~/") ? path.join(process.env.HOME ?? "", value.slice(2)) : value === "$TMPDIR" || value === "${TMPDIR}" ? root : value.startsWith("$TMPDIR/") ? path.join(root, value.slice(8)) : value.startsWith("${TMPDIR}/") ? path.join(root, value.slice(11)) : value;
|
|
169
|
+
const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(directory, expanded);
|
|
170
|
+
return realpath(candidate);
|
|
171
|
+
}
|
|
172
|
+
export async function fileIsRegular(value) {
|
|
173
|
+
const info = await stat(value).catch(() => undefined);
|
|
174
|
+
return info?.isFile() === true;
|
|
175
|
+
}
|
package/dist/src/tui.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/tui.tsx
|
|
3
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
4
|
+
var tui_default = Plugin.define({
|
|
5
|
+
id: "max10240.sensitive-protection.tui",
|
|
6
|
+
setup: setupSensitiveProtectionTui
|
|
7
|
+
});
|
|
8
|
+
function setupSensitiveProtectionTui(context) {
|
|
9
|
+
if (context.options.enabled === false)
|
|
10
|
+
return;
|
|
11
|
+
context.keymap.layer(() => ({
|
|
12
|
+
commands: [{
|
|
13
|
+
id: "sensitive-protection.register",
|
|
14
|
+
title: "Protect sensitive file",
|
|
15
|
+
description: "Register an existing file as protected",
|
|
16
|
+
group: "Sensitive",
|
|
17
|
+
bind: false,
|
|
18
|
+
palette: true,
|
|
19
|
+
slash: {
|
|
20
|
+
name: "protect-sensitive-file-ui"
|
|
21
|
+
},
|
|
22
|
+
run: async () => {
|
|
23
|
+
const file = await context.ui.dialog.prompt({
|
|
24
|
+
title: "Protect sensitive file",
|
|
25
|
+
description: "Existing file path",
|
|
26
|
+
placeholder: "/absolute/path"
|
|
27
|
+
});
|
|
28
|
+
if (!file?.trim())
|
|
29
|
+
return;
|
|
30
|
+
const route = context.ui.router.current();
|
|
31
|
+
if (route.type !== "session") {
|
|
32
|
+
await context.ui.dialog.alert({
|
|
33
|
+
title: "Protect sensitive file",
|
|
34
|
+
message: "Open a session first"
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await context.client.session.command({
|
|
40
|
+
sessionID: route.sessionID,
|
|
41
|
+
name: "protect-sensitive-file",
|
|
42
|
+
text: file.trim()
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await context.ui.dialog.alert({
|
|
46
|
+
title: "Protection failed",
|
|
47
|
+
message: error instanceof Error ? error.message : "Unable to register this file"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}]
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
tui_default as default,
|
|
56
|
+
setupSensitiveProtectionTui
|
|
57
|
+
};
|
package/dist/tui.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "@max10240/opencode-plugin-sensitive-protection",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
4
|
+
"version": "1.0.14",
|
|
5
|
+
"description": "Register protected files and redact their contents before they enter model context",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js",
|
|
9
|
+
"./tui": "./dist/src/tui.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test test/*.test.ts && bun test --conditions=browser --preload @opentui/solid/preload test/tui",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"build": "tsc -p tsconfig.build.json && bun build.mjs",
|
|
15
|
+
"prepack": "node ../../scripts/sync-recommended-rules.mjs && npm run build"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/**/*.js",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"opencode",
|
|
23
|
+
"opencode-plugin",
|
|
24
|
+
"security",
|
|
25
|
+
"redaction"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@opencode/plugin": "^2.0.9"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "^1.3.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.8.2",
|
|
35
|
+
"@opentui/core": "^0.5.10",
|
|
36
|
+
"@opentui/solid": "^0.5.10",
|
|
37
|
+
"solid-js": "^1.9.15"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"registry": "https://registry.npmjs.org/",
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Max10240/opencode-plugin-unicorn.git",
|
|
46
|
+
"directory": "packages/opencode-plugin-sensitive-protection"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@opentui/core": ">=0.5.10",
|
|
50
|
+
"@opentui/solid": ">=0.5.10",
|
|
51
|
+
"solid-js": ">=1.9.0"
|
|
52
|
+
}
|
|
53
|
+
}
|