Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 65 additions & 180 deletions catalog.json

Large diffs are not rendered by default.

Binary file added packages/pi.workspace-file-guard-0.2.7.piplug
Binary file not shown.
10 changes: 5 additions & 5 deletions plugins/pi.workspace-file-guard/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 工作区文件护栏
# C盘防垃圾

把测试、临时、草稿、日志、缓存等垃圾文件关在当前工作区或 PI scratch 里。这是行为约束,不是磁盘锁。
防止模型把测试、日志、缓存、临时文件写到系统盘、桌面、下载。垃圾只待在当前项目的 `Temp` 或 scratch。

便携的 PI-Desktop skill 包:启用一次后,每个会话都会注入规则并注册分类工具。路径按当前操作系统、用户主目录、工作区和环境变量解析,不写死 `C:`、用户名或某台机器的目录。
启用一次即可。路径按当前机器解析,不写死盘符或用户名。这是行为约束,不是磁盘锁。

## 提供什么

Expand All @@ -17,7 +17,7 @@
| --- | --- |
| 用户要求保留的源码 | 工作区(`src/`、`tests/` 等) |
| 一次性草稿、转储、日志 | `$PI_SCRATCH_DIR` |
| 项目本地缓存 | `$project/.tmp/cache` |
| 项目本地缓存 / 一次性测试 | `$project/Temp/` |

禁止:桌面、下载、文档、系统临时目录;项目在其他盘时禁止写到系统盘;Program Files、`/usr`、`/Applications`。

Expand All @@ -32,7 +32,7 @@

## 安装

1. PI-Desktop → 扩展 → 安装插件包,选择 `packages/pi.workspace-file-guard-0.2.5.piplug`。
1. PI-Desktop → 扩展 → 安装插件包,选择 `packages/pi.workspace-file-guard-0.2.7.piplug`。
2. 若出现权限确认,勾选 `agent.prompt.inject` 与 `agent.tool.register`(当前宿主对本地 `.piplug` 可能按清单静默全授)。
3. 新开一个 Agent 会话,skill 才会注入。

Expand Down
105 changes: 99 additions & 6 deletions plugins/pi.workspace-file-guard/guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const TMP_DIRNAME = ".tmp";
const TMP_DIRNAME = "Temp";
const WIN = process.platform === "win32";
const CASE_INSENSITIVE = WIN || process.platform === "darwin";

Expand Down Expand Up @@ -283,28 +283,118 @@ function isFilesystemRoot(target) {
return compareKey(resolved) === compareKey(path.parse(resolved).root);
}

function isTempDumpName(target) {
return path.basename(resolvePath(target)).toLowerCase() === TMP_DIRNAME.toLowerCase();
}

function isReservedRoot(target) {
const resolved = resolvePath(target);
return junkRoots().some((junk) => {
const info = canonicalPath(junk);
return info.ok && compareKey(info.path) === compareKey(resolved);
if (isFilesystemRoot(resolved) || isTempDumpName(resolved)) return true;
const exact = uniquePaths([
home(),
piHome(),
piAgentHome(),
codexHome(),
path.join(home(), ".codex"),
path.join(home(), "AppData"),
path.join(home(), "AppData", "Local"),
path.join(home(), "AppData", "Roaming"),
path.join(home(), "AppData", "LocalLow"),
process.env.LOCALAPPDATA,
process.env.APPDATA,
]);
if (exact.some((item) => compareKey(item) === compareKey(resolved))) return true;
if (
junkRoots().some((junk) => {
const info = canonicalPath(junk);
return info.ok && compareKey(info.path) === compareKey(resolved);
})
) {
return true;
}
const nested = uniquePaths([
os.tmpdir(),
path.join(home(), "AppData", "Local", "Temp"),
process.env.TEMP,
process.env.TMP,
process.env.TMPDIR,
process.env.SystemRoot,
process.env.windir,
process.env.ProgramFiles,
process.env["ProgramFiles(x86)"],
process.env.ProgramW6432,
process.env.ProgramData,
path.join(piHome(), "logs"),
path.join(piHome(), "cache"),
path.join(codexHome(), "visualizations"),
path.join(codexHome(), "tmp"),
path.join(codexHome(), ".tmp"),
path.join(home(), ".codex", "visualizations"),
path.join(home(), ".codex", "tmp"),
path.join(home(), ".codex", ".tmp"),
...(WIN
? []
: [
"/tmp",
"/var/tmp",
"/var/cache",
"/var/log",
"/private/tmp",
"/private/var/tmp",
"/usr",
"/bin",
"/sbin",
"/etc",
"/opt",
"/System",
"/Library",
"/Applications",
]),
]);
return nested.some((root) => {
const info = canonicalPath(root);
if (!info.ok) return false;
return compareKey(info.path) === compareKey(resolved) || isRelativeTo(resolved, info.path);
});
}

function owningProjectFromTemp(target) {
let current = resolvePath(target);
let found = null;
while (true) {
if (isTempDumpName(current)) {
const parent = path.dirname(current);
if (parent !== current && !isFilesystemRoot(parent)) {
const junkHit = junkRoots().some((junk) => {
const info = canonicalPath(junk);
return info.ok && (compareKey(info.path) === compareKey(current) || isRelativeTo(current, info.path));
});
if (!junkHit && !isReservedRoot(parent)) found = parent;
}
}
const next = path.dirname(current);
if (next === current) break;
current = next;
}
return found;
}

function validateProjectRoot(input) {
const expanded = expandUser(input);
if (!isAbsolutePath(expanded)) {
return { ok: false, error: "project root must be an absolute path" };
}
const resolved = resolvePath(expanded);
const info = canonicalPath(resolved);
const owner = owningProjectFromTemp(resolved);
const candidate = owner || resolved;
const info = canonicalPath(candidate);
if (!info.ok || info.hadSymlink) {
return { ok: false, error: "project root cannot be safely canonicalized" };
}
if (isFilesystemRoot(info.path) || isReservedRoot(info.path)) {
return { ok: false, error: "project root is a reserved system or junk directory" };
}
return { ok: true, projectRoot: resolved };
return { ok: true, projectRoot: candidate };
}

function safeScratchCandidate(input) {
Expand Down Expand Up @@ -383,6 +473,7 @@ function envAssignments({ projectRoot, scratch } = {}) {
TMP: ephemeral,
TEMP: ephemeral,
TMPDIR: ephemeral,
PYTHONDONTWRITEBYTECODE: "1",
PYTHONPYCACHEPREFIX: path.join(ephemeral, "pycache"),
PIP_CACHE_DIR: path.join(cache, "pip"),
UV_CACHE_DIR: path.join(cache, "uv"),
Expand Down Expand Up @@ -572,7 +663,9 @@ module.exports = {
envAssignments,
formatEnv,
isRelativeTo,
isReservedRoot,
junkRoots,
owningProjectFromTemp,
resolvePath,
resolveToolRoot,
scratchRoot,
Expand Down
148 changes: 82 additions & 66 deletions plugins/pi.workspace-file-guard/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,84 +100,100 @@ async function toolTmpLayout(args) {
scratch,
layout: tmpLayout(projectRoot),
hint:
"Create these folders only when needed. Put throwaway files in scratch; keep durable source in the project tree.",
"Create these folders only when needed. Put throwaway files in Temp or scratch; keep durable source in the project tree.",
});
}

async function onLoad() {
const registered = [];
try {
await pi.agent.registerTool({
name: "project_root",
description:
"Resolve the active PI-Desktop workspace, PI scratch directory, and recommended .tmp layout. Fails if no workspace is open and root is omitted. Use before creating test/temp/scratch files.",
risk: "low",
schema: {
type: "object",
properties: {
root: { type: "string", description: "Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection." }
await pi.agent.registerTool({
name: "project_root",
description:
"Resolve the active PI-Desktop workspace, PI scratch directory, and recommended Temp layout. Fails if no workspace is open and root is omitted. Use before creating test/temp/scratch files.",
risk: "low",
schema: {
type: "object",
properties: {
root: {
type: "string",
description:
"Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection.",
},
},
},
},
execute: (args) => toolProjectRoot(args),
});
registered.push("project_root");

await pi.agent.registerTool({
name: "check_path",
description:
"Classify whether a write path is allowed. Read allowed, not ok: ok=true only means classification succeeded. Relative paths are resolved against the workspace, not the plugin process cwd. allowed=false must be redirected into the workspace or PI scratch.",
risk: "low",
schema: {
type: "object",
properties: {
path: { type: "string", description: "Path that would be written" },
root: { type: "string", description: "Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection." },
explicit: {
type: "boolean",
description:
"Set true only when this turn's user message named this absolute destination. The tool cannot verify that; Desktop/Downloads/temp stay forbidden anyway.",
execute: (args) => toolProjectRoot(args),
});
registered.push("project_root");

await pi.agent.registerTool({
name: "check_path",
description:
"Classify whether a write path is allowed. Read allowed, not ok: ok=true only means classification succeeded. Relative paths are resolved against the workspace, not the plugin process cwd. allowed=false must be redirected into the workspace or PI scratch.",
risk: "low",
schema: {
type: "object",
properties: {
path: { type: "string", description: "Path that would be written" },
root: {
type: "string",
description:
"Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection.",
},
explicit: {
type: "boolean",
description:
"Set true only when this turn's user message named this absolute destination. The tool cannot verify that; Desktop/Downloads/temp stay forbidden anyway.",
},
},
required: ["path"],
},
required: ["path"],
},
execute: (args) => toolCheckPath(args),
});
registered.push("check_path");

await pi.agent.registerTool({
name: "temp_env",
description:
"Return TMP/TEMP/cache environment assignments that keep tool junk inside the project .tmp and PI scratch. Fails if no workspace is open and root is omitted.",
risk: "low",
schema: {
type: "object",
properties: {
root: { type: "string", description: "Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection." },
shell: {
type: "string",
enum: ["powershell", "cmd", "bash", "json"],
description: "Script dialect for the env assignments",
execute: (args) => toolCheckPath(args),
});
registered.push("check_path");

await pi.agent.registerTool({
name: "temp_env",
description:
"Return TMP/TEMP/cache environment assignments that keep tool junk inside the project Temp and PI scratch. Fails if no workspace is open and root is omitted.",
risk: "low",
schema: {
type: "object",
properties: {
root: {
type: "string",
description:
"Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection.",
},
shell: {
type: "string",
enum: ["powershell", "cmd", "bash", "json"],
description: "Script dialect for the env assignments",
},
},
},
},
execute: (args) => toolTempEnv(args),
});
registered.push("temp_env");

await pi.agent.registerTool({
name: "tmp_layout",
description:
"Return the recommended $project/.tmp/{tests,scripts,cache,out} layout without creating files. Fails if no workspace is open and root is omitted.",
risk: "low",
schema: {
type: "object",
properties: {
root: { type: "string", description: "Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection." }
execute: (args) => toolTempEnv(args),
});
registered.push("temp_env");

await pi.agent.registerTool({
name: "tmp_layout",
description:
"Return the recommended $project/Temp/{tests,scripts,cache,out} layout without creating files. Fails if no workspace is open and root is omitted.",
risk: "low",
schema: {
type: "object",
properties: {
root: {
type: "string",
description:
"Optional absolute project root override; use only for a root explicitly selected by the user. The tool cannot verify that selection.",
},
},
},
},
execute: (args) => toolTmpLayout(args),
});
registered.push("tmp_layout");
execute: (args) => toolTmpLayout(args),
});
registered.push("tmp_layout");
} catch (error) {
for (const name of registered.reverse()) {
try {
Expand Down
Loading
Loading