decorated-pi 0.2.0 → 0.2.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.
- package/README.md +59 -144
- package/extensions/extend-model.ts +1 -6
- package/extensions/index.ts +10 -6
- package/extensions/lsp/client.ts +12 -1
- package/extensions/lsp/env.ts +6 -0
- package/extensions/lsp/format.ts +6 -0
- package/extensions/lsp/index.ts +6 -0
- package/extensions/lsp/prompt.ts +6 -0
- package/extensions/lsp/server-manager.ts +6 -0
- package/extensions/lsp/servers.ts +9 -1
- package/extensions/lsp/tools.ts +8 -0
- package/extensions/lsp/trust.ts +6 -0
- package/extensions/providers/qianfan-coding.ts +1 -1
- package/extensions/safety/detect.ts +736 -0
- package/extensions/safety/index.ts +155 -0
- package/extensions/settings.ts +31 -0
- package/extensions/slash.ts +107 -7
- package/extensions/smart-at.ts +10 -3
- package/package.json +10 -7
- package/extensions/safety.ts +0 -371
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety — Pi 集成层
|
|
3
|
+
*
|
|
4
|
+
* - Command Guard: 拦截危险 bash 命令
|
|
5
|
+
* - Redirect Guard: bash 覆盖写入提示确认
|
|
6
|
+
* - Protected Paths: write/edit/read 保护路径提示确认
|
|
7
|
+
* - Write Guard: 覆盖非空文件禁止 write
|
|
8
|
+
* - Secret Redact: API Key / Token 自动掩码
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
ToolResultEvent,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
checkProtectedPath,
|
|
20
|
+
collectBashDangers,
|
|
21
|
+
formatBashDangers,
|
|
22
|
+
detectSecrets,
|
|
23
|
+
maskSecret,
|
|
24
|
+
} from "./detect.js";
|
|
25
|
+
|
|
26
|
+
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
27
|
+
|
|
28
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export function setupSafety(pi: ExtensionAPI) {
|
|
31
|
+
// ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
|
|
32
|
+
|
|
33
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
34
|
+
|
|
35
|
+
// Gate 1: 危险命令 + 覆盖写入 + 读取保护路径
|
|
36
|
+
if (event.toolName === "bash") {
|
|
37
|
+
const command = (event.input as { command?: string }).command;
|
|
38
|
+
if (command) {
|
|
39
|
+
const dangers = collectBashDangers(command, ctx.cwd);
|
|
40
|
+
if (dangers.length > 0) {
|
|
41
|
+
const message = formatBashDangers(dangers)!;
|
|
42
|
+
if (!ctx.hasUI) {
|
|
43
|
+
return { block: true, reason: `⚠ ${message} (non-interactive)` };
|
|
44
|
+
}
|
|
45
|
+
const choice = await ctx.ui.select(
|
|
46
|
+
`⚠️ ${message}\n\nAllow execution?`,
|
|
47
|
+
["Block", "Allow once"],
|
|
48
|
+
);
|
|
49
|
+
if (!choice || choice === "Block") {
|
|
50
|
+
return { block: true, reason: `⚠ ${message}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Gate 2: write/edit 写入保护路径
|
|
57
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
58
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
59
|
+
if (filePath) {
|
|
60
|
+
const danger = checkProtectedPath(filePath);
|
|
61
|
+
if (danger) {
|
|
62
|
+
if (!ctx.hasUI) {
|
|
63
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
64
|
+
}
|
|
65
|
+
const choice = await ctx.ui.select(
|
|
66
|
+
`🔒 ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
67
|
+
["Block", "Allow once"],
|
|
68
|
+
);
|
|
69
|
+
if (!choice || choice === "Block") {
|
|
70
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
|
|
77
|
+
if (event.toolName === "write") {
|
|
78
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
79
|
+
if (filePath) {
|
|
80
|
+
try {
|
|
81
|
+
const abs = resolve(ctx.cwd, filePath);
|
|
82
|
+
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
83
|
+
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
|
|
84
|
+
}
|
|
85
|
+
} catch { /* file doesn't exist */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Gate 4: read 工具读取保护路径(bash 读取已在 Gate 1 处理)
|
|
90
|
+
if (event.toolName === "read") {
|
|
91
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
92
|
+
if (filePath) {
|
|
93
|
+
const danger = checkProtectedPath(filePath);
|
|
94
|
+
if (danger) {
|
|
95
|
+
if (!ctx.hasUI) {
|
|
96
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
97
|
+
}
|
|
98
|
+
const choice = await ctx.ui.select(
|
|
99
|
+
`🔒 Reading protected file: ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
100
|
+
["Block", "Allow once"],
|
|
101
|
+
);
|
|
102
|
+
if (!choice || choice === "Block") {
|
|
103
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Secret Redact (tool_result) ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const handleToolResult = async (
|
|
113
|
+
event: ToolResultEvent,
|
|
114
|
+
ctx: ExtensionContext,
|
|
115
|
+
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
116
|
+
if (!event.content || !Array.isArray(event.content)) return;
|
|
117
|
+
|
|
118
|
+
// Only scan read tool output — other tools (bash, write, edit) are either
|
|
119
|
+
// covered by path guards or produce git/diff noise that causes false positives.
|
|
120
|
+
if (event.toolName !== "read") return;
|
|
121
|
+
|
|
122
|
+
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
123
|
+
for (let i = 0; i < event.content.length; i++) {
|
|
124
|
+
const item = event.content[i];
|
|
125
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
126
|
+
textParts.push({ index: i, text: item.text, item });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (textParts.length === 0) return;
|
|
130
|
+
|
|
131
|
+
let totalCount = 0;
|
|
132
|
+
const newContent = [...event.content];
|
|
133
|
+
|
|
134
|
+
for (const { index, text, item } of textParts) {
|
|
135
|
+
const matches = detectSecrets(text);
|
|
136
|
+
if (matches.length === 0) continue;
|
|
137
|
+
|
|
138
|
+
totalCount += matches.length;
|
|
139
|
+
let redacted = text;
|
|
140
|
+
for (const { start, end } of matches) {
|
|
141
|
+
const original = redacted.slice(start, end);
|
|
142
|
+
redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
|
|
143
|
+
}
|
|
144
|
+
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
145
|
+
newContent[index] = updatedItem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (totalCount === 0) return;
|
|
149
|
+
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
150
|
+
ctx.ui.notify(`🔒 Redacted ${label} in ${event.toolName} output`, "warning");
|
|
151
|
+
return { content: newContent };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
pi.on("tool_result", handleToolResult);
|
|
155
|
+
}
|
package/extensions/settings.ts
CHANGED
|
@@ -25,10 +25,17 @@ export interface ProviderCache {
|
|
|
25
25
|
models: ProviderModelEntry[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface ModuleSettings {
|
|
29
|
+
safety?: boolean;
|
|
30
|
+
lsp?: boolean;
|
|
31
|
+
"smart-at"?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
export interface DecoratedPiConfig {
|
|
29
35
|
imageModelKey?: string | null;
|
|
30
36
|
compactModelKey?: string | null;
|
|
31
37
|
providers?: Record<string, ProviderCache>;
|
|
38
|
+
modules?: ModuleSettings;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export function loadConfig(): DecoratedPiConfig {
|
|
@@ -97,3 +104,27 @@ export function setImageModelKey(key: string | null) {
|
|
|
97
104
|
export function setCompactModelKey(key: string | null) {
|
|
98
105
|
saveConfig({ compactModelKey: key });
|
|
99
106
|
}
|
|
107
|
+
|
|
108
|
+
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
111
|
+
safety: true,
|
|
112
|
+
lsp: true,
|
|
113
|
+
"smart-at": true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function isModuleEnabled(name: keyof ModuleSettings): boolean {
|
|
117
|
+
const modules = loadConfig().modules ?? {};
|
|
118
|
+
return modules[name] ?? DEFAULT_MODULES[name] ?? true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function setModuleEnabled(name: keyof ModuleSettings, enabled: boolean) {
|
|
122
|
+
const modules = { ...loadConfig().modules };
|
|
123
|
+
modules[name] = enabled;
|
|
124
|
+
saveConfig({ modules });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
128
|
+
const modules = loadConfig().modules ?? {};
|
|
129
|
+
return { ...DEFAULT_MODULES, ...modules };
|
|
130
|
+
}
|
package/extensions/slash.ts
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slash — 所有扩展命令
|
|
3
3
|
*
|
|
4
|
-
* /
|
|
5
|
-
* /
|
|
4
|
+
* /dp-model → 模型选择器 (TAB 切换 Image/Compact)
|
|
5
|
+
* /dp-settings → 模块开关 (safety / lsp / smart-at)
|
|
6
|
+
* /retry → 中断后继续
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { ModelPickerComponent } from "./extend-model.js";
|
|
11
|
+
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
|
|
12
|
+
import { Container, SettingsList, type TUI, type Theme as PiTheme, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
|
|
10
13
|
|
|
11
|
-
// ───
|
|
14
|
+
// ─── Border component (matches native DynamicBorder) ────────────────────────
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
class DynamicBorder implements Component {
|
|
17
|
+
private colorFn: (str: string) => string;
|
|
18
|
+
|
|
19
|
+
constructor(theme: PiTheme) {
|
|
20
|
+
this.colorFn = (str: string) => theme.fg("border", str);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
invalidate() {}
|
|
24
|
+
|
|
25
|
+
render(width: number): string[] {
|
|
26
|
+
return [this.colorFn("─".repeat(Math.max(1, width)))];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── SettingsList Theme (matches native getSettingsListTheme) ───────────────
|
|
31
|
+
|
|
32
|
+
function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
|
|
33
|
+
return {
|
|
34
|
+
label: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : text,
|
|
35
|
+
value: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
|
|
36
|
+
description: (text: string) => theme.fg("dim", text),
|
|
37
|
+
cursor: theme.fg("accent", "→ "),
|
|
38
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── /dp-model ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function setupDpModelCommand(pi: ExtensionAPI) {
|
|
45
|
+
pi.registerCommand("dp-model", {
|
|
15
46
|
description: "Configure image and compact models",
|
|
16
47
|
handler: async (_args, ctx) => {
|
|
17
48
|
if (ctx.hasUI) {
|
|
@@ -21,7 +52,75 @@ function setupExtendModelCommand(pi: ExtensionAPI) {
|
|
|
21
52
|
);
|
|
22
53
|
return;
|
|
23
54
|
}
|
|
24
|
-
ctx.ui.notify("
|
|
55
|
+
ctx.ui.notify("dp-model requires interactive mode.", "warning");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── /dp-settings ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
|
|
63
|
+
safety: "Safety Layer",
|
|
64
|
+
lsp: "LSP Tools",
|
|
65
|
+
"smart-at": "Smart @ Search",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
|
|
69
|
+
safety: "Command guard, protected paths, read guard, secret redaction",
|
|
70
|
+
lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
|
|
71
|
+
"smart-at": "Project-aware file search replacing default autocomplete",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
class ModuleSettingsComponent extends Container {
|
|
75
|
+
private settingsList: SettingsList;
|
|
76
|
+
|
|
77
|
+
constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
|
|
78
|
+
super();
|
|
79
|
+
const modules = getAllModuleSettings();
|
|
80
|
+
const keys = Object.keys(MODULE_LABELS) as (keyof ModuleSettings)[];
|
|
81
|
+
|
|
82
|
+
const items = keys.map(k => ({
|
|
83
|
+
id: k,
|
|
84
|
+
label: MODULE_LABELS[k],
|
|
85
|
+
description: MODULE_DESCS[k],
|
|
86
|
+
currentValue: modules[k] ? "on" : "off",
|
|
87
|
+
values: ["on", "off"],
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
this.addChild(new DynamicBorder(theme));
|
|
91
|
+
|
|
92
|
+
this.settingsList = new SettingsList(
|
|
93
|
+
items, 10, getSettingsListTheme(theme),
|
|
94
|
+
(id: string, newValue: string) => {
|
|
95
|
+
setModuleEnabled(id as keyof ModuleSettings, newValue === "on");
|
|
96
|
+
tui.requestRender();
|
|
97
|
+
},
|
|
98
|
+
() => onDone(),
|
|
99
|
+
{ enableSearch: true },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
this.addChild(this.settingsList);
|
|
103
|
+
this.addChild(new DynamicBorder(theme));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
handleInput(data: string) {
|
|
107
|
+
this.settingsList.handleInput(data);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setupDpSettingsCommand(pi: ExtensionAPI) {
|
|
112
|
+
pi.registerCommand("dp-settings", {
|
|
113
|
+
description: "Toggle decorated-pi modules on/off",
|
|
114
|
+
handler: async (_args, ctx) => {
|
|
115
|
+
if (ctx.hasUI) {
|
|
116
|
+
await ctx.ui.custom<void>(
|
|
117
|
+
(tui, theme, _kb, done) =>
|
|
118
|
+
new ModuleSettingsComponent(tui, theme, () => done(undefined))
|
|
119
|
+
);
|
|
120
|
+
ctx.ui.notify("Module settings updated. /reload to apply.", "info");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
ctx.ui.notify("dp-settings requires interactive mode.", "warning");
|
|
25
124
|
},
|
|
26
125
|
});
|
|
27
126
|
}
|
|
@@ -62,6 +161,7 @@ function setupRetryCommand(pi: ExtensionAPI) {
|
|
|
62
161
|
// ─── 入口 ───────────────────────────────────────────────────────────────────
|
|
63
162
|
|
|
64
163
|
export function setupSlash(pi: ExtensionAPI) {
|
|
65
|
-
|
|
164
|
+
setupDpModelCommand(pi);
|
|
165
|
+
setupDpSettingsCommand(pi);
|
|
66
166
|
setupRetryCommand(pi);
|
|
67
167
|
}
|
package/extensions/smart-at.ts
CHANGED
|
@@ -200,9 +200,13 @@ export function setupSmartAt(pi: ExtensionAPI) {
|
|
|
200
200
|
|
|
201
201
|
const { dirs, files } = getFileAndDirList(cwd);
|
|
202
202
|
const results = smartSearch(dirs, files, prefix.slice(1));
|
|
203
|
-
ctx.ui.setWidget("smart-at", ["[2mpowered by decorated-pi[0m"]);
|
|
204
203
|
|
|
205
|
-
if (!results.length)
|
|
204
|
+
if (!results.length) {
|
|
205
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ctx.ui.setWidget("smart-at", ["[2mpowered by decorated-pi[0m"]);
|
|
206
210
|
return Promise.resolve({
|
|
207
211
|
items: results.map((f: string) => ({
|
|
208
212
|
value: "@" + f,
|
|
@@ -213,7 +217,10 @@ export function setupSmartAt(pi: ExtensionAPI) {
|
|
|
213
217
|
});
|
|
214
218
|
},
|
|
215
219
|
// ⚠️ 必须 .bind(orig)
|
|
216
|
-
applyCompletion:
|
|
220
|
+
applyCompletion: (...args: any[]) => {
|
|
221
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
222
|
+
return orig.applyCompletion.apply(orig, args);
|
|
223
|
+
},
|
|
217
224
|
shouldTriggerFileCompletion: orig.shouldTriggerFileCompletion?.bind(orig),
|
|
218
225
|
}));
|
|
219
226
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorated-pi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Essential utilities for pi: safety gates, secret redaction, smart @ completion, dynamic AGENTS loading, image fallback, and LSP tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -11,20 +11,17 @@
|
|
|
11
11
|
"lsp",
|
|
12
12
|
"language-server",
|
|
13
13
|
"autocomplete",
|
|
14
|
-
"
|
|
14
|
+
"entropy",
|
|
15
|
+
"secret-detection"
|
|
15
16
|
],
|
|
16
17
|
"license": "MIT",
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
|
-
"url": "https://github.com/lcwecker/decorated-pi.git"
|
|
20
|
+
"url": "git+https://github.com/lcwecker/decorated-pi.git"
|
|
20
21
|
},
|
|
21
22
|
"homepage": "https://github.com/lcwecker/decorated-pi#readme",
|
|
22
23
|
"bugs": "https://github.com/lcwecker/decorated-pi/issues",
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@secretlint/node": "^13.0.0",
|
|
25
|
-
"@secretlint/secretlint-rule-azure": "^13.0.0",
|
|
26
|
-
"@secretlint/secretlint-rule-preset-recommend": "^13.0.0",
|
|
27
|
-
"@secretlint/secretlint-rule-secp256k1-privatekey": "^13.0.0",
|
|
28
25
|
"@spences10/pi-child-env": "0.1.4",
|
|
29
26
|
"@spences10/pi-project-trust": "0.0.6",
|
|
30
27
|
"openai": "^6.37.0"
|
|
@@ -39,5 +36,11 @@
|
|
|
39
36
|
"extensions": [
|
|
40
37
|
"./extensions/index.ts"
|
|
41
38
|
]
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"vitest": "^4.1.6"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run"
|
|
42
45
|
}
|
|
43
46
|
}
|