decorated-pi 0.2.2 → 0.3.0
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 +44 -60
- package/extensions/file-times.ts +66 -0
- package/extensions/index.ts +4 -2
- package/extensions/io.ts +406 -0
- package/extensions/lsp/tools.ts +59 -1
- package/extensions/{extend-model.ts → model-integration.ts} +127 -4
- package/extensions/patch.ts +624 -0
- package/extensions/safety/detect.ts +170 -75
- package/extensions/safety/index.ts +54 -15
- package/extensions/settings.ts +2 -0
- package/extensions/slash.ts +6 -4
- package/extensions/smart-at.ts +339 -111
- package/package.json +2 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
-
import { resolve } from "node:path";
|
|
11
|
+
import { basename, extname, resolve } from "node:path";
|
|
12
12
|
|
|
13
13
|
const DANGEROUS_COMMANDS: [string, string[]][] = [
|
|
14
14
|
["rm", []],
|
|
@@ -332,7 +332,7 @@ export function shannonEntropy(data: string): number {
|
|
|
332
332
|
* Trigram (3-character sliding window) scoring.
|
|
333
333
|
* Rules (user-specified):
|
|
334
334
|
* - Pure digits → 0
|
|
335
|
-
* - Letter↔Digit switch (digit in first
|
|
335
|
+
* - Letter↔Digit switch (digit in first position, e.g. 4Vi) → 1.0
|
|
336
336
|
* - Contains '-' with ≥3 distinct classes → 1.0
|
|
337
337
|
* - Case switch AbA pattern (≥2 uppercase + ≥1 lowercase) → 0.8
|
|
338
338
|
* - Otherwise → 0
|
|
@@ -351,10 +351,10 @@ export function trigramScore(c1: string, c2: string, c3: string): number {
|
|
|
351
351
|
// Contains '-' (S-class) with ≥3 distinct classes → 1.0
|
|
352
352
|
if (cls.includes("S") && unique.size >= 3) return 1.0;
|
|
353
353
|
|
|
354
|
-
// Letter↔Digit: digit must be in first
|
|
354
|
+
// Letter↔Digit: digit must be in first position
|
|
355
355
|
const hasDigit = cls.includes("D");
|
|
356
356
|
const hasLetter = cls.includes("L") || cls.includes("U");
|
|
357
|
-
if (hasDigit && hasLetter &&
|
|
357
|
+
if (hasDigit && hasLetter && cls[0] === "D") return 1.0;
|
|
358
358
|
|
|
359
359
|
// AbA pattern: ≥2 uppercase + ≥1 lowercase (e.g. KeA, but not API)
|
|
360
360
|
const uCount = cls.filter(c => c === "U").length;
|
|
@@ -412,35 +412,26 @@ export function maxSegmentDensity(token: string): number {
|
|
|
412
412
|
|
|
413
413
|
/**
|
|
414
414
|
* Word ratio: fraction of token that consists of vowel-containing
|
|
415
|
-
*
|
|
416
|
-
* the likelihood of being a secret.
|
|
415
|
+
* alphabetic fragments ≥3 characters, case-insensitive. Natural language
|
|
416
|
+
* words reduce the likelihood of being a secret.
|
|
417
417
|
*/
|
|
418
418
|
export function computeWordRatio(token: string): number {
|
|
419
|
-
|
|
420
|
-
const segments: string[] = [];
|
|
419
|
+
const letterSeqs: string[] = [];
|
|
421
420
|
let current = "";
|
|
422
|
-
let prevClass = "";
|
|
423
421
|
for (const c of token) {
|
|
424
422
|
const cls = charClass(c);
|
|
425
|
-
if (cls === "
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
if (cls !== prevClass && current.length > 0) {
|
|
431
|
-
segments.push(current);
|
|
423
|
+
if (cls === "L" || cls === "U") {
|
|
424
|
+
current += c.toLowerCase();
|
|
425
|
+
} else {
|
|
426
|
+
if (current.length >= 3) letterSeqs.push(current);
|
|
432
427
|
current = "";
|
|
433
428
|
}
|
|
434
|
-
current += c;
|
|
435
|
-
prevClass = cls;
|
|
436
429
|
}
|
|
437
|
-
if (current.length
|
|
430
|
+
if (current.length >= 3) letterSeqs.push(current);
|
|
438
431
|
|
|
439
432
|
let wordLen = 0;
|
|
440
|
-
for (const
|
|
441
|
-
if (
|
|
442
|
-
if (/[aeiou]/.test(seg)) wordLen += seg.length;
|
|
443
|
-
}
|
|
433
|
+
for (const seq of letterSeqs) {
|
|
434
|
+
if (/[aeiou]/.test(seq)) wordLen += seq.length;
|
|
444
435
|
}
|
|
445
436
|
return token.length > 0 ? wordLen / token.length : 0;
|
|
446
437
|
}
|
|
@@ -474,20 +465,22 @@ const DICT_WORDS: ReadonlySet<string> = new Set(
|
|
|
474
465
|
/**
|
|
475
466
|
* Dictionary word ratio: fraction of token characters covered by dictionary words.
|
|
476
467
|
*
|
|
477
|
-
* Extracts
|
|
478
|
-
* the longest dictionary word at each position. Returns matched character
|
|
468
|
+
* Extracts alphabetic sequences from the token (case-insensitive), then greedily
|
|
469
|
+
* matches the longest dictionary word at each position. Returns matched character
|
|
479
470
|
* count / token length.
|
|
480
471
|
*
|
|
481
472
|
* "devstral-small-2" → finds "dev", "str", "small" → covers 11/16 chars
|
|
482
|
-
* "
|
|
473
|
+
* "NET_CHANNEL_INFO_REPORT_V20" → finds "net", "channel", "info", "report"
|
|
474
|
+
* "aB3xK9mPqR7wN" → no words found → dictRatio = 0
|
|
483
475
|
*/
|
|
484
476
|
export function computeDictRatio(token: string): number {
|
|
485
|
-
// Extract
|
|
477
|
+
// Extract alphabetic sequences (>= 3 chars), case-insensitive
|
|
486
478
|
const lowerSeqs: string[] = [];
|
|
487
479
|
let current = "";
|
|
488
480
|
for (const c of token) {
|
|
489
|
-
|
|
490
|
-
|
|
481
|
+
const cls = charClass(c);
|
|
482
|
+
if (cls === "L" || cls === "U") {
|
|
483
|
+
current += c.toLowerCase();
|
|
491
484
|
} else {
|
|
492
485
|
if (current.length >= 3) lowerSeqs.push(current);
|
|
493
486
|
current = "";
|
|
@@ -524,7 +517,7 @@ export function computeDictRatio(token: string): number {
|
|
|
524
517
|
// ── Entropy Constants ────────────────────────────────────────────────────────
|
|
525
518
|
|
|
526
519
|
export const ENTROPY_THRESHOLD = 5.5;
|
|
527
|
-
export const MIN_ENTROPY_TOKEN_LENGTH =
|
|
520
|
+
export const MIN_ENTROPY_TOKEN_LENGTH = 32;
|
|
528
521
|
export const W1_DENSITY = 3.0;
|
|
529
522
|
export const W2_WORD = 3.0;
|
|
530
523
|
export const W3_DICT = 4.0;
|
|
@@ -657,19 +650,118 @@ export function isSafeContent(content: string): boolean {
|
|
|
657
650
|
|
|
658
651
|
// ── Detector ─────────────────────────────────────────────────────────────────
|
|
659
652
|
|
|
653
|
+
export type SecretMatchSource = "pattern" | "regex" | "entropy";
|
|
654
|
+
|
|
660
655
|
export interface SecretMatch {
|
|
661
656
|
name: string;
|
|
662
657
|
start: number;
|
|
663
658
|
end: number;
|
|
664
659
|
original: string;
|
|
660
|
+
source: SecretMatchSource;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export interface DetectSecretsOptions {
|
|
664
|
+
filePath?: string;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
interface ConfigStringEntry {
|
|
668
|
+
key: string;
|
|
669
|
+
normalizedKey: string;
|
|
670
|
+
value: string;
|
|
671
|
+
start: number;
|
|
672
|
+
end: number;
|
|
665
673
|
}
|
|
666
674
|
|
|
667
675
|
const MIN_SCAN_LENGTH = 10;
|
|
676
|
+
const CONFIG_VALUE_MIN_LENGTH = 32;
|
|
677
|
+
const CONFIG_FILE_EXTENSIONS = new Set([
|
|
678
|
+
".json", ".jsonc", ".env", ".toml", ".yaml", ".yml",
|
|
679
|
+
".ini", ".cfg", ".conf", ".properties",
|
|
680
|
+
]);
|
|
681
|
+
const CONFIG_BASENAME_REGEX = /^\.env(?:\..+)?$/i;
|
|
682
|
+
const SENSITIVE_CONFIG_KEY_REGEX = /(?:^|_)(?:apikey|api_(?:key|secret|token)|access_(?:key|token)|refresh_token|client_secret|secret(?:_key)?|private_key|bearer_token|auth(?:orization|_token)?|pass(?:word|wd)?|pwd|token|webhook_secret)(?:_|$)/i;
|
|
683
|
+
const PLACEHOLDER_VALUE_REGEX = /^(?:\$\{[^}]+\}|\{\{[^}]+\}\}|<[^>]+>|xxx+|placeholder|example|sample|demo|test|changeme|your[_-]?(?:api[_-]?)?key(?:[_-]?here)?)$/i;
|
|
684
|
+
const CONFIG_STRING_PATTERNS: RegExp[] = [
|
|
685
|
+
/(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*"(?<value>(?:\\.|[^"\\])*)"/g,
|
|
686
|
+
/(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*'(?<value>(?:\\.|[^'\\])*)'/g,
|
|
687
|
+
/(?<key>[A-Za-z0-9_.-]+)\s*=\s*(?<value>[^\r\n#;]+)/g,
|
|
688
|
+
];
|
|
689
|
+
|
|
690
|
+
function normalizeConfigKey(key: string): string {
|
|
691
|
+
return key
|
|
692
|
+
.trim()
|
|
693
|
+
.replace(/^['"]|['"]$/g, "")
|
|
694
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
695
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
696
|
+
.toLowerCase()
|
|
697
|
+
.replace(/[.\-\s]+/g, "_")
|
|
698
|
+
.replace(/_+/g, "_")
|
|
699
|
+
.replace(/^_+|_+$/g, "");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isConfigLikeFile(filePath?: string): boolean {
|
|
703
|
+
if (!filePath) return false;
|
|
704
|
+
const name = basename(filePath);
|
|
705
|
+
if (CONFIG_BASENAME_REGEX.test(name)) return true;
|
|
706
|
+
return CONFIG_FILE_EXTENSIONS.has(extname(name).toLowerCase());
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function looksLikeSensitiveConfigValue(value: string): boolean {
|
|
710
|
+
const trimmed = value.trim();
|
|
711
|
+
if (!trimmed) return false;
|
|
712
|
+
if (PLACEHOLDER_VALUE_REGEX.test(trimmed)) return false;
|
|
713
|
+
if (isSafeContent(trimmed)) return false;
|
|
714
|
+
if (/^(?:true|false|null)$/i.test(trimmed)) return false;
|
|
715
|
+
if (/^[+-]?\d+(?:\.\d+)?$/.test(trimmed)) return false;
|
|
716
|
+
return trimmed.length >= CONFIG_VALUE_MIN_LENGTH;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function extractConfigStringEntries(content: string): ConfigStringEntry[] {
|
|
720
|
+
const entries: ConfigStringEntry[] = [];
|
|
721
|
+
const seen = new Set<string>();
|
|
722
|
+
|
|
723
|
+
for (const pattern of CONFIG_STRING_PATTERNS) {
|
|
724
|
+
for (const match of content.matchAll(pattern)) {
|
|
725
|
+
const key = match.groups?.key;
|
|
726
|
+
const value = match.groups?.value;
|
|
727
|
+
if (!key || value === undefined || match.index === undefined) continue;
|
|
728
|
+
const full = match[0] ?? "";
|
|
729
|
+
const rel = full.indexOf(value);
|
|
730
|
+
if (rel < 0) continue;
|
|
731
|
+
const start = match.index + rel;
|
|
732
|
+
const end = start + value.length;
|
|
733
|
+
const dedupeKey = `${start}-${end}`;
|
|
734
|
+
if (seen.has(dedupeKey)) continue;
|
|
735
|
+
seen.add(dedupeKey);
|
|
736
|
+
entries.push({
|
|
737
|
+
key,
|
|
738
|
+
normalizedKey: normalizeConfigKey(key),
|
|
739
|
+
value,
|
|
740
|
+
start,
|
|
741
|
+
end,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return entries;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function addMatch(matches: SecretMatch[], seen: Set<string>, match: SecretMatch): void {
|
|
750
|
+
const key = `${match.start}-${match.end}`;
|
|
751
|
+
if (seen.has(key)) return;
|
|
752
|
+
seen.add(key);
|
|
753
|
+
matches.push(match);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function isCoveredByExistingMatch(matches: SecretMatch[], start: number, end: number): boolean {
|
|
757
|
+
return matches.some((existing) => !(end <= existing.start || start >= existing.end));
|
|
758
|
+
}
|
|
668
759
|
|
|
669
|
-
export function detectSecrets(content: string): SecretMatch[] {
|
|
760
|
+
export function detectSecrets(content: string, options: DetectSecretsOptions = {}): SecretMatch[] {
|
|
670
761
|
if (content.length < MIN_SCAN_LENGTH) return [];
|
|
671
762
|
const matches: SecretMatch[] = [];
|
|
672
|
-
const seen = new Set<string>();
|
|
763
|
+
const seen = new Set<string>();
|
|
764
|
+
const configLike = isConfigLikeFile(options.filePath);
|
|
673
765
|
|
|
674
766
|
// Pass 1: High-confidence pattern matching (specific prefixes like ghp_, AKIA)
|
|
675
767
|
for (const sp of SECRET_PATTERNS) {
|
|
@@ -677,60 +769,63 @@ export function detectSecrets(content: string): SecretMatch[] {
|
|
|
677
769
|
if (content.length < sp.minLength) continue;
|
|
678
770
|
for (const m of content.matchAll(new RegExp(sp.pattern.source, sp.pattern.flags + "g"))) {
|
|
679
771
|
const text = m[0];
|
|
680
|
-
if (!text) continue;
|
|
772
|
+
if (!text || m.index === undefined) continue;
|
|
681
773
|
if (!sp.allowsSpaces && text.includes(" ")) continue;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
774
|
+
addMatch(matches, seen, {
|
|
775
|
+
name: sp.name,
|
|
776
|
+
start: m.index,
|
|
777
|
+
end: m.index + text.length,
|
|
778
|
+
original: text,
|
|
779
|
+
source: "pattern",
|
|
780
|
+
});
|
|
686
781
|
}
|
|
687
782
|
}
|
|
688
783
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
// Skip if range already covered by a high-confidence match
|
|
705
|
-
const start = m.index!, end = m.index! + text.length;
|
|
706
|
-
if (matches.some(hc => hc.start <= start && hc.end >= end)) continue;
|
|
707
|
-
const key = `${start}-${end}`;
|
|
708
|
-
if (seen.has(key)) continue;
|
|
709
|
-
seen.add(key);
|
|
710
|
-
matches.push({ name: sp.name, start, end, original: text });
|
|
784
|
+
if (configLike) {
|
|
785
|
+
const entries = extractConfigStringEntries(content);
|
|
786
|
+
|
|
787
|
+
// Pass 2: Regex key-name matching for config-like files only
|
|
788
|
+
for (const entry of entries) {
|
|
789
|
+
if (!SENSITIVE_CONFIG_KEY_REGEX.test(entry.normalizedKey)) continue;
|
|
790
|
+
if (!looksLikeSensitiveConfigValue(entry.value)) continue;
|
|
791
|
+
if (isCoveredByExistingMatch(matches, entry.start, entry.end)) continue;
|
|
792
|
+
addMatch(matches, seen, {
|
|
793
|
+
name: `Sensitive config key: ${entry.normalizedKey}`,
|
|
794
|
+
start: entry.start,
|
|
795
|
+
end: entry.end,
|
|
796
|
+
original: entry.value,
|
|
797
|
+
source: "regex",
|
|
798
|
+
});
|
|
711
799
|
}
|
|
712
|
-
}
|
|
713
800
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
801
|
+
// Pass 3: Entropy analysis for config-like files only
|
|
802
|
+
for (const entry of entries) {
|
|
803
|
+
if (isCoveredByExistingMatch(matches, entry.start, entry.end)) continue;
|
|
804
|
+
if (!looksLikeSensitiveConfigValue(entry.value)) continue;
|
|
805
|
+
if (!isHighEntropy(entry.value)) continue;
|
|
806
|
+
addMatch(matches, seen, {
|
|
807
|
+
name: "High Entropy String",
|
|
808
|
+
start: entry.start,
|
|
809
|
+
end: entry.end,
|
|
810
|
+
original: entry.value,
|
|
811
|
+
source: "entropy",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
726
814
|
}
|
|
727
815
|
|
|
728
816
|
// Sort by start position descending for safe right-to-left replacement
|
|
729
817
|
return matches.sort((a, b) => b.start - a.start);
|
|
730
818
|
}
|
|
731
819
|
|
|
732
|
-
|
|
733
|
-
if (
|
|
734
|
-
|
|
820
|
+
function getMaskChar(source?: SecretMatchSource): string {
|
|
821
|
+
if (source === "regex") return "#";
|
|
822
|
+
if (source === "entropy") return "?";
|
|
823
|
+
return "*";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function maskSecret(text: string, source?: SecretMatchSource): string {
|
|
827
|
+
const maskChar = getMaskChar(source);
|
|
828
|
+
if (text.length <= 6) return maskChar.repeat(text.length);
|
|
829
|
+
return text.slice(0, 3) + maskChar.repeat(text.length - 6) + text.slice(-3);
|
|
735
830
|
}
|
|
736
831
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - Command Guard: 拦截危险 bash 命令
|
|
5
5
|
* - Redirect Guard: bash 覆盖写入提示确认
|
|
6
|
-
* - Protected Paths: write/edit/read 保护路径提示确认
|
|
7
|
-
* - Write Guard: 覆盖非空文件禁止 write
|
|
6
|
+
* - Protected Paths: write/edit/patch/read 保护路径提示确认
|
|
7
|
+
* - Write Guard: 覆盖非空文件禁止 write (提示使用 patch)
|
|
8
8
|
* - Secret Redact: API Key / Token 自动掩码
|
|
9
9
|
*/
|
|
10
10
|
|
|
@@ -25,6 +25,26 @@ import {
|
|
|
25
25
|
|
|
26
26
|
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
27
27
|
|
|
28
|
+
function summarizeCommand(command: string, maxLength = 48): string {
|
|
29
|
+
const singleLine = command.replace(/\s+/g, " ").trim();
|
|
30
|
+
if (singleLine.length <= maxLength) return singleLine;
|
|
31
|
+
return `${singleLine.slice(0, maxLength - 1)}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRedactionContext(event: ToolResultEvent): string {
|
|
35
|
+
if (event.toolName === "read") {
|
|
36
|
+
const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
|
|
37
|
+
return filePath ? `read ${filePath}` : "read";
|
|
38
|
+
}
|
|
39
|
+
if (event.toolName === "bash") {
|
|
40
|
+
const command = (event.input as any)?.command;
|
|
41
|
+
return typeof command === "string" && command.trim().length > 0
|
|
42
|
+
? `bash ${summarizeCommand(command)}`
|
|
43
|
+
: "bash";
|
|
44
|
+
}
|
|
45
|
+
return event.toolName;
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
29
49
|
|
|
30
50
|
export function setupSafety(pi: ExtensionAPI) {
|
|
@@ -53,10 +73,13 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
53
73
|
}
|
|
54
74
|
}
|
|
55
75
|
|
|
56
|
-
// Gate 2: write/edit 写入保护路径
|
|
57
|
-
if (event.toolName === "write" || event.toolName === "edit") {
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
// Gate 2: write/edit/patch 写入保护路径
|
|
77
|
+
if (event.toolName === "write" || event.toolName === "edit" || event.toolName === "patch") {
|
|
78
|
+
// For write/edit, path is a single field; for patch, check all patches[].path
|
|
79
|
+
const filePaths: string[] = event.toolName === "patch"
|
|
80
|
+
? (event.input as any).patches?.filter((p: any) => p?.path).map((p: any) => p.path) ?? []
|
|
81
|
+
: [(event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path].filter(Boolean);
|
|
82
|
+
for (const filePath of filePaths) {
|
|
60
83
|
const danger = checkProtectedPath(filePath);
|
|
61
84
|
if (danger) {
|
|
62
85
|
if (!ctx.hasUI) {
|
|
@@ -69,6 +92,7 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
69
92
|
if (!choice || choice === "Block") {
|
|
70
93
|
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
71
94
|
}
|
|
95
|
+
break; // User approved — skip remaining paths
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
}
|
|
@@ -80,7 +104,7 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
80
104
|
try {
|
|
81
105
|
const abs = resolve(ctx.cwd, filePath);
|
|
82
106
|
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
83
|
-
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the
|
|
107
|
+
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the patch tool instead!" };
|
|
84
108
|
}
|
|
85
109
|
} catch { /* file doesn't exist */ }
|
|
86
110
|
}
|
|
@@ -115,9 +139,10 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
115
139
|
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
116
140
|
if (!event.content || !Array.isArray(event.content)) return;
|
|
117
141
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
142
|
+
// Scan read + bash tool output. Skip write/edit/patch because they mainly
|
|
143
|
+
// produce diffs or generated file bodies, which are handled elsewhere and are
|
|
144
|
+
// more prone to noisy false positives.
|
|
145
|
+
if (event.toolName !== "read" && event.toolName !== "bash") return;
|
|
121
146
|
|
|
122
147
|
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
123
148
|
for (let i = 0; i < event.content.length; i++) {
|
|
@@ -129,17 +154,25 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
129
154
|
if (textParts.length === 0) return;
|
|
130
155
|
|
|
131
156
|
let totalCount = 0;
|
|
157
|
+
const counts: Record<"pattern" | "regex" | "entropy", number> = {
|
|
158
|
+
pattern: 0,
|
|
159
|
+
regex: 0,
|
|
160
|
+
entropy: 0,
|
|
161
|
+
};
|
|
132
162
|
const newContent = [...event.content];
|
|
133
163
|
|
|
164
|
+
const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
|
|
165
|
+
|
|
134
166
|
for (const { index, text, item } of textParts) {
|
|
135
|
-
const matches = detectSecrets(text);
|
|
167
|
+
const matches = detectSecrets(text, { filePath });
|
|
136
168
|
if (matches.length === 0) continue;
|
|
137
169
|
|
|
138
170
|
totalCount += matches.length;
|
|
139
171
|
let redacted = text;
|
|
140
|
-
for (const { start, end } of matches) {
|
|
172
|
+
for (const { start, end, source } of matches) {
|
|
173
|
+
counts[source] += 1;
|
|
141
174
|
const original = redacted.slice(start, end);
|
|
142
|
-
redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
|
|
175
|
+
redacted = redacted.slice(0, start) + maskSecret(original, source) + redacted.slice(end);
|
|
143
176
|
}
|
|
144
177
|
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
145
178
|
newContent[index] = updatedItem;
|
|
@@ -147,9 +180,15 @@ export function setupSafety(pi: ExtensionAPI) {
|
|
|
147
180
|
|
|
148
181
|
if (totalCount === 0) return;
|
|
149
182
|
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
150
|
-
|
|
183
|
+
const breakdown: string[] = [];
|
|
184
|
+
if (counts.pattern > 0) breakdown.push(`*:pattern=${counts.pattern}`);
|
|
185
|
+
if (counts.regex > 0) breakdown.push(`#:regex=${counts.regex}`);
|
|
186
|
+
if (counts.entropy > 0) breakdown.push(`?:entropy=${counts.entropy}`);
|
|
187
|
+
const suffix = breakdown.length > 0 ? ` · ${breakdown.join(" ")}` : "";
|
|
188
|
+
const contextLabel = formatRedactionContext(event);
|
|
189
|
+
ctx.ui.notify(`🔒 [${contextLabel}] Redacted ${label}${suffix}`, "warning");
|
|
151
190
|
return { content: newContent };
|
|
152
191
|
};
|
|
153
192
|
|
|
154
193
|
pi.on("tool_result", handleToolResult);
|
|
155
|
-
}
|
|
194
|
+
}
|
package/extensions/settings.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface ModuleSettings {
|
|
|
29
29
|
safety?: boolean;
|
|
30
30
|
lsp?: boolean;
|
|
31
31
|
"smart-at"?: boolean;
|
|
32
|
+
patch?: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface DecoratedPiConfig {
|
|
@@ -111,6 +112,7 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
|
111
112
|
safety: true,
|
|
112
113
|
lsp: true,
|
|
113
114
|
"smart-at": true,
|
|
115
|
+
patch: true,
|
|
114
116
|
};
|
|
115
117
|
|
|
116
118
|
export function isModuleEnabled(name: keyof ModuleSettings): boolean {
|
package/extensions/slash.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Slash — 所有扩展命令
|
|
3
3
|
*
|
|
4
4
|
* /dp-model → 模型选择器 (TAB 切换 Image/Compact)
|
|
5
|
-
* /dp-settings → 模块开关 (safety / lsp / smart-at)
|
|
5
|
+
* /dp-settings → 模块开关 (patch / safety / lsp / smart-at)
|
|
6
6
|
* /retry → 中断后继续
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { ModelPickerComponent } from "./
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { ModelPickerComponent } from "./model-integration.js";
|
|
11
11
|
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
|
|
12
|
-
import { Container, SettingsList, type TUI, type
|
|
12
|
+
import { Container, SettingsList, type TUI, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
|
|
13
13
|
|
|
14
14
|
// ─── Border component (matches native DynamicBorder) ────────────────────────
|
|
15
15
|
|
|
@@ -60,12 +60,14 @@ function setupDpModelCommand(pi: ExtensionAPI) {
|
|
|
60
60
|
// ─── /dp-settings ──────────────────────────────────────────────────────────
|
|
61
61
|
|
|
62
62
|
const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
|
|
63
|
+
patch: "Patch Tool",
|
|
63
64
|
safety: "Safety Layer",
|
|
64
65
|
lsp: "LSP Tools",
|
|
65
66
|
"smart-at": "Smart @ Search",
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
|
|
70
|
+
patch: "Replace edit/write with patch tool (old_str/new_str replacement + overwrite)",
|
|
69
71
|
safety: "Command guard, protected paths, read guard, secret redaction",
|
|
70
72
|
lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
|
|
71
73
|
"smart-at": "Project-aware file search replacing default autocomplete",
|