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.
@@ -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
+ }
@@ -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
+ }
@@ -1,17 +1,48 @@
1
1
  /**
2
2
  * Slash — 所有扩展命令
3
3
  *
4
- * /extend-model → 模型选择器 (TAB 切换 Image/Compact)
5
- * /retry 中断后继续
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
- // ─── /extend-model ─────────────────────────────────────────────────────────
14
+ // ─── Border component (matches native DynamicBorder) ────────────────────────
12
15
 
13
- function setupExtendModelCommand(pi: ExtensionAPI) {
14
- pi.registerCommand("extend-model", {
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("extend-model requires interactive mode.", "warning");
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
- setupExtendModelCommand(pi);
164
+ setupDpModelCommand(pi);
165
+ setupDpSettingsCommand(pi);
66
166
  setupRetryCommand(pi);
67
167
  }
@@ -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", ["powered by decorated-pi"]);
204
203
 
205
- if (!results.length) return null;
204
+ if (!results.length) {
205
+ ctx.ui.setWidget("smart-at", undefined);
206
+ return null;
207
+ }
208
+
209
+ ctx.ui.setWidget("smart-at", ["powered by decorated-pi"]);
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: orig.applyCompletion.bind(orig),
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.0",
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
- "secretlint"
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
  }