decorated-pi 0.2.1 → 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 CHANGED
@@ -63,7 +63,7 @@ Supported languages:
63
63
 
64
64
  ### 4. Auxiliary Models (Image + Compact)
65
65
 
66
- Uses cheaper models for auxiliary tasks, configured via `/extend-model`:
66
+ Uses cheaper models for auxiliary tasks, configured via `/dp-model`:
67
67
 
68
68
  - **Image read fallback** — when the model reads an image file, detects type via magic bytes, calls a configured vision-capable model, and replaces the read result with image analysis text (jpeg, png, gif, webp)
69
69
  - **Compact model** — uses a configured model for context compaction (instead of the main model), auto-resumes after compaction.
@@ -104,6 +104,30 @@ Runtime settings are stored in:
104
104
  ~/.pi/agent/decorated-pi.json
105
105
  ```
106
106
 
107
+ ### Module Loading
108
+
109
+ Modules can be toggled on/off. Changes take effect after `/reload`.
110
+
111
+ | Module | Default | Effect when disabled |
112
+ | -------- | --------- | --------------------- |
113
+ | `safety` | `true` | No command guard, no protected path check, no secret redaction |
114
+ | `lsp` | `true` | All `lsp_*` tools unregistered — no diagnostics, hover, etc. |
115
+ | `smart-at` | `true` | Fallback to Pi's built-in `@` file completion |
116
+
117
+ Use `/dp-settings` to toggle, or edit the config file directly:
118
+
119
+ ```json
120
+ {
121
+ "modules": {
122
+ "safety": true,
123
+ "lsp": false,
124
+ "smart-at": true
125
+ }
126
+ }
127
+ ```
128
+
129
+ Omitted keys default to `true` (enabled).
130
+
107
131
  ## License
108
132
 
109
133
  MIT
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { setupSafety } from "./safety";
6
+ import { setupSafety } from "./safety/index.js";
7
7
  import { setupExtendModel } from "./extend-model";
8
8
  import { setupSlash } from "./slash";
9
9
  import { setupSubdirAgents } from "./subdir-agents";
@@ -12,15 +12,19 @@ import { setupGuidance } from "./guidance";
12
12
  import { setupLsp } from "./lsp/index";
13
13
  import { setupProviders } from "./providers/index";
14
14
  import { setupSmartAt } from "./smart-at";
15
+ import { isModuleEnabled } from "./settings";
15
16
 
16
17
  export default function (pi: ExtensionAPI) {
17
- setupSafety(pi);
18
- setupExtendModel(pi);
18
+ // Always loaded — core commands and providers
19
19
  setupSlash(pi);
20
+ setupProviders(pi);
21
+ setupExtendModel(pi);
20
22
  setupSubdirAgents(pi);
21
23
  setupSessionTitle(pi);
22
24
  setupGuidance(pi);
23
- setupLsp(pi);
24
- setupProviders(pi);
25
- setupSmartAt(pi);
25
+
26
+ // Configurable modules
27
+ if (isModuleEnabled("safety")) setupSafety(pi);
28
+ if (isModuleEnabled("lsp")) setupLsp(pi);
29
+ if (isModuleEnabled("smart-at")) setupSmartAt(pi);
26
30
  }
@@ -25,7 +25,7 @@ const MODELS: ProviderModelConfig[] = [
25
25
  { id: "glm-4.7", name: "GLM 4.7", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
26
26
  { id: "glm-5", name: "GLM 5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
27
27
  { id: "glm-5.1", name: "GLM 5.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
28
- { id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262_144, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
28
+ { id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 229_376, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
29
29
  { id: "minimax-m2.1", name: "MiniMax M2.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
30
30
  { id: "minimax-m2.5", name: "MiniMax M2.5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
31
31
  { id: "ernie-4.5-turbo-20260402", name: "ERNIE 4.5 Turbo", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 12_288, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
@@ -1,24 +1,15 @@
1
1
  /**
2
- * Safety — 安全防护模块
2
+ * Safety Detection 纯逻辑层(零 Pi 依赖)
3
3
  *
4
- * - Command Guard: 拦截危险 bash 命令(rm, sudo, npm publish, git push 等)
5
- * - Redirect Guard: bash 覆盖写入(>)提示确认,保护路径额外警告敏感信息
6
- * - Protected Paths: write/edit 写入保护路径需确认,提示敏感信息
7
- * - Read Guard: read/cat 等读取保护路径需确认,提示敏感信息
8
- * - Write Guard: 覆盖非空文件禁止 write 工具,建议用 edit
9
- * - Secret Redact: API Key / Token 自动掩码
4
+ * - Command Guard: 危险命令检测 + 覆盖写入检测 + 读取保护路径检测
5
+ * - Secret Detection: 40+ 高置信模式 + 熵分析 V3+Dict + 安全模式排除
6
+ *
7
+ * 本模块可独立测试,不依赖 Pi API。
10
8
  */
11
9
 
12
- import type {
13
- ExtensionAPI,
14
- ExtensionContext,
15
- ToolResultEvent,
16
- } from "@earendil-works/pi-coding-agent";
17
10
  import * as fs from "node:fs";
18
11
  import { resolve } from "node:path";
19
12
 
20
- // ─── 危险命令枚举 ──────────────────────────────────────────────────────────
21
-
22
13
  const DANGEROUS_COMMANDS: [string, string[]][] = [
23
14
  ["rm", []],
24
15
  ["sudo", []],
@@ -56,7 +47,7 @@ const READ_COMMANDS = new Set([
56
47
  "file", "strings", "grep", "rg", "ag", "ack",
57
48
  ]);
58
49
 
59
- function checkProtectedPath(filePath: string): string | null {
50
+ export function checkProtectedPath(filePath: string): string | null {
60
51
  const normalized = filePath.replace(/\\/g, "/");
61
52
  const filename = normalized.split("/").pop() ?? "";
62
53
  for (const seg of PROTECTED_PATH_SEGMENTS) {
@@ -73,7 +64,7 @@ function checkProtectedPath(filePath: string): string | null {
73
64
 
74
65
  // ─── Shell tokenizer ────────────────────────────────────────────────────────
75
66
 
76
- function tokenizeShell(command: string): string[] {
67
+ export function tokenizeShell(command: string): string[] {
77
68
  const tokens: string[] = [];
78
69
  let current = "";
79
70
  let quote: "'" | '"' | null = null;
@@ -164,13 +155,13 @@ function isExistingRegularFile(target: string, cwd: string): boolean {
164
155
 
165
156
  // ─── Bash danger analysis ───────────────────────────────────────────────────
166
157
 
167
- interface BashDanger {
158
+ export interface BashDanger {
168
159
  reason: string;
169
160
  /** Whether the danger involves a protected (sensitive) path */
170
161
  protectedPath?: string;
171
162
  }
172
163
 
173
- function collectBashDangers(command: string, cwd: string): BashDanger[] {
164
+ export function collectBashDangers(command: string, cwd: string): BashDanger[] {
174
165
  const tokens = tokenizeShell(command);
175
166
  const dangers: BashDanger[] = [];
176
167
  const seen = new Set<string>();
@@ -261,7 +252,7 @@ function collectBashDangers(command: string, cwd: string): BashDanger[] {
261
252
  return dangers;
262
253
  }
263
254
 
264
- function formatBashDangers(dangers: BashDanger[]): string | null {
255
+ export function formatBashDangers(dangers: BashDanger[]): string | null {
265
256
  if (dangers.length === 0) return null;
266
257
  if (dangers.length === 1) return dangers[0]!.reason;
267
258
  return `dangerous operations detected:\n- ${dangers.map(d => d.reason).join("\n- ")}`;
@@ -290,9 +281,6 @@ function formatBashDangers(dangers: BashDanger[]): string | null {
290
281
  // - dictRatio: dictionary word coverage penalizes identifiers/English text
291
282
  // - hexPenalty: -2.5 only if >90% hex AND contains '-' (UUID-like format)
292
283
 
293
- type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
294
-
295
- // ── Entropy Analysis v3+Dict ─────────────────────────────────────────────────
296
284
  //
297
285
  // Based on opencode-secrets-protect by Jared Scheel
298
286
  // https://github.com/jscheel/opencode-secrets-protect (MIT License)
@@ -312,7 +300,7 @@ type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number],
312
300
  // 6. hexPenalty: -2.5 only if >90% hex AND contains '-' (UUID-like format)
313
301
 
314
302
  /** Character class: U=uppercase, L=lowercase, D=digit, S=dash, X=other */
315
- function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
303
+ export function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
316
304
  const code = c.charCodeAt(0);
317
305
  if (code >= 65 && code <= 90) return "U";
318
306
  if (code >= 97 && code <= 122) return "L";
@@ -325,7 +313,7 @@ function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
325
313
  * Shannon entropy: measures average information content per character.
326
314
  * H(X) = -Σ p(x) · log₂(p(x))
327
315
  */
328
- function shannonEntropy(data: string): number {
316
+ export function shannonEntropy(data: string): number {
329
317
  if (data.length === 0) return 0;
330
318
  const freq = new Map<string, number>();
331
319
  for (const char of data) {
@@ -349,7 +337,7 @@ function shannonEntropy(data: string): number {
349
337
  * - Case switch AbA pattern (≥2 uppercase + ≥1 lowercase) → 0.8
350
338
  * - Otherwise → 0
351
339
  */
352
- function trigramScore(c1: string, c2: string, c3: string): number {
340
+ export function trigramScore(c1: string, c2: string, c3: string): number {
353
341
  const cls: string[] = [charClass(c1), charClass(c2), charClass(c3)];
354
342
 
355
343
  // Any X-class character → skip
@@ -380,7 +368,7 @@ function trigramScore(c1: string, c2: string, c3: string): number {
380
368
  * Split a token by X-class characters into independent segments.
381
369
  * This prevents `://`, `@`, `.` etc. from diluting trigram density.
382
370
  */
383
- function splitByXClass(token: string): string[] {
371
+ export function splitByXClass(token: string): string[] {
384
372
  const segments: string[] = [];
385
373
  let current = "";
386
374
  for (const c of token) {
@@ -398,7 +386,7 @@ function splitByXClass(token: string): string[] {
398
386
  /**
399
387
  * Compute average trigram density for a single segment.
400
388
  */
401
- function segmentDensity(segment: string): number {
389
+ export function segmentDensity(segment: string): number {
402
390
  if (segment.length < 3) return 0;
403
391
  let totalScore = 0;
404
392
  for (let i = 0; i <= segment.length - 3; i++) {
@@ -411,7 +399,7 @@ function segmentDensity(segment: string): number {
411
399
  * Compute the maximum segment density across all X-split segments.
412
400
  * The segment with the highest density is the most likely secret region.
413
401
  */
414
- function maxSegmentDensity(token: string): number {
402
+ export function maxSegmentDensity(token: string): number {
415
403
  const segments = splitByXClass(token);
416
404
  if (segments.length === 0) return 0;
417
405
  let maxD = 0;
@@ -427,7 +415,7 @@ function maxSegmentDensity(token: string): number {
427
415
  * lowercase fragments ≥3 characters. Natural language words reduce
428
416
  * the likelihood of being a secret.
429
417
  */
430
- function computeWordRatio(token: string): number {
418
+ export function computeWordRatio(token: string): number {
431
419
  // Split by class boundaries
432
420
  const segments: string[] = [];
433
421
  let current = "";
@@ -461,7 +449,7 @@ function computeWordRatio(token: string): number {
461
449
  * Hex ratio: fraction of characters that are hex characters (0-9, a-f, A-F, -).
462
450
  * Values >0.9 indicate UUIDs or hex hashes which are safe.
463
451
  */
464
- function computeHexRatio(token: string): number {
452
+ export function computeHexRatio(token: string): number {
465
453
  let hexChars = 0;
466
454
  for (const c of token) {
467
455
  if (/[0-9a-fA-F\-]/.test(c)) hexChars++;
@@ -493,7 +481,7 @@ const DICT_WORDS: ReadonlySet<string> = new Set(
493
481
  * "devstral-small-2" → finds "dev", "str", "small" → covers 11/16 chars
494
482
  * "aB3xK9mPqR7wN" → no words found → dictRatio = 0
495
483
  */
496
- function computeDictRatio(token: string): number {
484
+ export function computeDictRatio(token: string): number {
497
485
  // Extract lowercase letter sequences (>= 3 chars)
498
486
  const lowerSeqs: string[] = [];
499
487
  let current = "";
@@ -535,19 +523,19 @@ function computeDictRatio(token: string): number {
535
523
 
536
524
  // ── Entropy Constants ────────────────────────────────────────────────────────
537
525
 
538
- const ENTROPY_THRESHOLD = 5.5;
539
- const MIN_ENTROPY_TOKEN_LENGTH = 16;
540
- const W1_DENSITY = 3.0; // trigram density weight
541
- const W2_WORD = 3.0; // vowel-word penalty weight
542
- const W3_DICT = 4.0; // dictionary word penalty weight
543
- const HEX_PENALTY = 2.5; // penalty for >90% hex chars
544
- const HEX_RATIO_THRESHOLD = 0.9;
526
+ export const ENTROPY_THRESHOLD = 5.5;
527
+ export const MIN_ENTROPY_TOKEN_LENGTH = 16;
528
+ export const W1_DENSITY = 3.0;
529
+ export const W2_WORD = 3.0;
530
+ export const W3_DICT = 4.0;
531
+ export const HEX_PENALTY = 2.5;
532
+ export const HEX_RATIO_THRESHOLD = 0.9;
545
533
 
546
534
  /**
547
535
  * Adjusted entropy v3+Dict:
548
536
  * adjusted = baseShannon + trigramDensity×W1 - wordRatio×W2 - dictRatio×W3 - hexPenalty
549
537
  */
550
- function calculateAdjustedEntropy(data: string): number {
538
+ export function calculateAdjustedEntropy(data: string): number {
551
539
  const base = shannonEntropy(data);
552
540
  const density = maxSegmentDensity(data);
553
541
  const wordRatio = computeWordRatio(data);
@@ -563,7 +551,7 @@ function calculateAdjustedEntropy(data: string): number {
563
551
  return base + densityBoost - wordPenalty - dictPenalty - hp;
564
552
  }
565
553
 
566
- function isHighEntropy(data: string): boolean {
554
+ export function isHighEntropy(data: string): boolean {
567
555
  if (data.length < MIN_ENTROPY_TOKEN_LENGTH) return false;
568
556
  if (isSafeContent(data)) return false;
569
557
  return calculateAdjustedEntropy(data) > ENTROPY_THRESHOLD;
@@ -573,14 +561,14 @@ function isHighEntropy(data: string): boolean {
573
561
  * Split by whitespace only — the most conservative tokenization.
574
562
  * This preserves JSON structure, URLs, and connection strings.
575
563
  */
576
- function findHighEntropyTokens(content: string): string[] {
564
+ export function findHighEntropyTokens(content: string): string[] {
577
565
  const tokens = content.split(/[\s\[\]{}"',\/\\|()&#@!<>?]+/);
578
566
  return tokens.filter(t => t.length >= MIN_ENTROPY_TOKEN_LENGTH && isHighEntropy(t));
579
567
  }
580
568
 
581
569
  // ── Known Secret Patterns ────────────────────────────────────────────────────
582
570
 
583
- interface SecretPattern {
571
+ export interface SecretPattern {
584
572
  name: string;
585
573
  pattern: RegExp;
586
574
  minLength: number;
@@ -589,7 +577,7 @@ interface SecretPattern {
589
577
  highConfidence: boolean;
590
578
  }
591
579
 
592
- const SECRET_PATTERNS: SecretPattern[] = [
580
+ export const SECRET_PATTERNS: SecretPattern[] = [
593
581
  // AWS
594
582
  { name: "AWS Access Key ID", pattern: /AKIA[0-9A-Z]{16}/, minLength: 16, allowsSpaces: false, highConfidence: true },
595
583
  { name: "AWS Secret Access Key", pattern: /(?:aws)?_?(?:secret)?_?(?:access)?_?key['"\s:=]+['"]?[0-9a-zA-Z/+]{40}['"]?/i, minLength: 30, allowsSpaces: false, highConfidence: true },
@@ -646,7 +634,7 @@ const SECRET_PATTERNS: SecretPattern[] = [
646
634
 
647
635
  // ── Safe Patterns (exclude from detection to reduce false positives) ─────────
648
636
 
649
- const SAFE_PATTERNS: RegExp[] = [
637
+ export const SAFE_PATTERNS: RegExp[] = [
650
638
  /^https?:\/\/[a-zA-Z0-9.-]+(?:\/[a-zA-Z0-9.\/_\-?&=#%]*)?$/, // URLs without credentials
651
639
  /^\.\.?\/[a-zA-Z0-9_\-./]+$/, // Relative file paths
652
640
  /^\/[a-zA-Z0-9_\-./]+$/, // Absolute Unix paths
@@ -660,7 +648,7 @@ const SAFE_PATTERNS: RegExp[] = [
660
648
  /^@[a-z0-9-]+\/[a-z0-9-]+$/, // npm scoped packages
661
649
  ];
662
650
 
663
- function isSafeContent(content: string): boolean {
651
+ export function isSafeContent(content: string): boolean {
664
652
  for (const pat of SAFE_PATTERNS) {
665
653
  if (pat.test(content)) return true;
666
654
  }
@@ -669,7 +657,7 @@ function isSafeContent(content: string): boolean {
669
657
 
670
658
  // ── Detector ─────────────────────────────────────────────────────────────────
671
659
 
672
- interface SecretMatch {
660
+ export interface SecretMatch {
673
661
  name: string;
674
662
  start: number;
675
663
  end: number;
@@ -678,7 +666,7 @@ interface SecretMatch {
678
666
 
679
667
  const MIN_SCAN_LENGTH = 10;
680
668
 
681
- function detectSecrets(content: string): SecretMatch[] {
669
+ export function detectSecrets(content: string): SecretMatch[] {
682
670
  if (content.length < MIN_SCAN_LENGTH) return [];
683
671
  const matches: SecretMatch[] = [];
684
672
  const seen = new Set<string>(); // deduplicate by position
@@ -741,136 +729,8 @@ function detectSecrets(content: string): SecretMatch[] {
741
729
  return matches.sort((a, b) => b.start - a.start);
742
730
  }
743
731
 
744
- function maskSecret(text: string): string {
732
+ export function maskSecret(text: string): string {
745
733
  if (text.length <= 8) return "********";
746
734
  return text.slice(0, 4) + "********" + text.slice(-4);
747
735
  }
748
736
 
749
- // ─── Setup ──────────────────────────────────────────────────────────────────
750
-
751
- export function setupSafety(pi: ExtensionAPI) {
752
- // ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
753
-
754
- pi.on("tool_call", async (event, ctx) => {
755
-
756
- // Gate 1: 危险命令 + 覆盖写入 + 读取保护路径
757
- if (event.toolName === "bash") {
758
- const command = (event.input as { command?: string }).command;
759
- if (command) {
760
- const dangers = collectBashDangers(command, ctx.cwd);
761
- if (dangers.length > 0) {
762
- const message = formatBashDangers(dangers)!;
763
- if (!ctx.hasUI) {
764
- return { block: true, reason: `\u26D4 ${message} (non-interactive)` };
765
- }
766
- const choice = await ctx.ui.select(
767
- `\u26A0\uFE0F ${message}\n\nAllow execution?`,
768
- ["Block", "Allow once"],
769
- );
770
- if (!choice || choice === "Block") {
771
- return { block: true, reason: `\u26D4 ${message}` };
772
- }
773
- }
774
- }
775
- }
776
-
777
- // Gate 2: write/edit 写入保护路径
778
- if (event.toolName === "write" || event.toolName === "edit") {
779
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
780
- if (filePath) {
781
- const danger = checkProtectedPath(filePath);
782
- if (danger) {
783
- if (!ctx.hasUI) {
784
- return { block: true, reason: `\uD83D\uDD10 ${danger}\nmay contain sensitive information` };
785
- }
786
- const choice = await ctx.ui.select(
787
- `\uD83D\uDD10 ${danger}\nmay contain sensitive information\n\nProceed?`,
788
- ["Block", "Allow once"],
789
- );
790
- if (!choice || choice === "Block") {
791
- return { block: true, reason: `\uD83D\uDD10 ${danger}\nmay contain sensitive information` };
792
- }
793
- }
794
- }
795
- }
796
-
797
- // Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
798
- if (event.toolName === "write") {
799
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
800
- if (filePath) {
801
- try {
802
- const abs = resolve(ctx.cwd, filePath);
803
- if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
804
- return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
805
- }
806
- } catch { /* file doesn't exist */ }
807
- }
808
- }
809
-
810
- // Gate 4: read 工具读取保护路径(bash 读取已在 Gate 1 处理)
811
- if (event.toolName === "read") {
812
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
813
- if (filePath) {
814
- const danger = checkProtectedPath(filePath);
815
- if (danger) {
816
- if (!ctx.hasUI) {
817
- return { block: true, reason: `\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information` };
818
- }
819
- const choice = await ctx.ui.select(
820
- `\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information\n\nProceed?`,
821
- ["Block", "Allow once"],
822
- );
823
- if (!choice || choice === "Block") {
824
- return { block: true, reason: `\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information` };
825
- }
826
- }
827
- }
828
- }
829
- });
830
-
831
- // ── Secret Redact (tool_result) ────────────────────────────────────────
832
-
833
- const handleToolResult = async (
834
- event: ToolResultEvent,
835
- ctx: ExtensionContext,
836
- ): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
837
- if (!event.content || !Array.isArray(event.content)) return;
838
-
839
- // Only scan read tool output — other tools (bash, write, edit) are either
840
- // covered by path guards or produce git/diff noise that causes false positives.
841
- if (event.toolName !== "read") return;
842
-
843
- const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
844
- for (let i = 0; i < event.content.length; i++) {
845
- const item = event.content[i];
846
- if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
847
- textParts.push({ index: i, text: item.text, item });
848
- }
849
- }
850
- if (textParts.length === 0) return;
851
-
852
- let totalCount = 0;
853
- const newContent = [...event.content];
854
-
855
- for (const { index, text, item } of textParts) {
856
- const matches = detectSecrets(text);
857
- if (matches.length === 0) continue;
858
-
859
- totalCount += matches.length;
860
- let redacted = text;
861
- for (const { start, end } of matches) {
862
- const original = redacted.slice(start, end);
863
- redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
864
- }
865
- const updatedItem: ToolTextContent = { ...item, text: redacted };
866
- newContent[index] = updatedItem;
867
- }
868
-
869
- if (totalCount === 0) return;
870
- const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
871
- ctx.ui.notify(`\uD83D\uDD10 Redacted ${label} in ${event.toolName} output`, "warning");
872
- return { content: newContent };
873
- };
874
-
875
- pi.on("tool_result", handleToolResult);
876
- }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.2.1",
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",