mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的 RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展 直接读取已安装组合包内的 config/,pi install / pi update 即完成部署; 删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留 日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本 reset 回发布版本。AGENTS.md 新增「组合包配置原则」。 脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程 安装器改为先下载到临时文件再执行,下载失败可被检测。 权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装 组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、 yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、 web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
loadUnifiedConfig,
|
|
normalizeFlatPermissionValue,
|
|
stripJsonComments,
|
|
} from "./config-loader";
|
|
import { getGlobalConfigPath } from "./config-paths";
|
|
import type { ScopeConfig } from "./types";
|
|
import { toRecord } from "./value-guards";
|
|
import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File-stamp helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getFileStamp(path: string): string {
|
|
try {
|
|
return String(statSync(path).mtimeMs);
|
|
} catch {
|
|
return "missing";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP server-name reading helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function readConfiguredMcpServerNamesFromConfigPath(
|
|
configPath: string,
|
|
): string[] {
|
|
try {
|
|
const raw = readFileSync(configPath, "utf-8");
|
|
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
const root = toRecord(parsed);
|
|
const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
|
|
|
|
return Object.keys(serverRecord)
|
|
.map((name) => name.trim())
|
|
.filter((name) => name.length > 0);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function getConfiguredMcpServerNamesFromPaths(
|
|
paths: readonly string[],
|
|
): string[] {
|
|
const seen = new Set<string>();
|
|
|
|
for (const path of paths) {
|
|
for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
|
|
seen.add(name);
|
|
}
|
|
}
|
|
|
|
return [...seen].sort(
|
|
(left, right) => right.length - left.length || left.localeCompare(right),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolved policy paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ResolvedPolicyPaths {
|
|
globalConfigPath: string;
|
|
globalConfigExists: boolean;
|
|
agentsDir: string;
|
|
agentsDirExists: boolean;
|
|
projectAgentsDir: string | null;
|
|
projectAgentsDirExists: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PolicyLoader interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Abstraction over file I/O for loading permission policy from disk.
|
|
* Implementations handle caching, path resolution, and config-issue
|
|
* accumulation. `PermissionManager` depends on this interface so that
|
|
* merge + evaluation logic can be tested with an in-memory stub.
|
|
*/
|
|
export interface PolicyLoader {
|
|
loadGlobalConfig(): ScopeConfig;
|
|
loadAgentConfig(agentName?: string): ScopeConfig;
|
|
loadProjectAgentConfig(agentName?: string): ScopeConfig;
|
|
getConfiguredMcpServerNames(): readonly string[];
|
|
/** Combined mtime stamp for cache invalidation. */
|
|
getCacheStamp(agentName?: string): string;
|
|
/** Accumulated config-parse issues across all loads. */
|
|
getConfigIssues(): string[];
|
|
/** Resolved paths for the /permission-system show command. */
|
|
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Default path factories (deferred until call-time, not module scope)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function defaultGlobalConfigPath(): string {
|
|
return getGlobalConfigPath();
|
|
}
|
|
function defaultAgentsDir(): string {
|
|
return join(getAgentDir(), "agents");
|
|
}
|
|
function defaultGlobalMcpConfigPath(): string {
|
|
return join(getAgentDir(), "mcp.json");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File cache helper type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type FileCacheEntry<TValue> = {
|
|
stamp: string;
|
|
value: TValue;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Options shared between FilePolicyLoader and the backward-compat
|
|
// PermissionManager constructor.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PolicyLoaderOptions {
|
|
globalConfigPath?: string;
|
|
agentsDir?: string;
|
|
projectAgentsDir?: string;
|
|
globalMcpConfigPath?: string;
|
|
mcpServerNames?: readonly string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FilePolicyLoader — the production implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Production `PolicyLoader` that reads config files from disk with
|
|
* mtime-based caching.
|
|
*/
|
|
export class FilePolicyLoader implements PolicyLoader {
|
|
private readonly globalConfigPath: string;
|
|
private readonly agentsDir: string;
|
|
private readonly projectAgentsDir: string | null;
|
|
private readonly globalMcpConfigPath: string;
|
|
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
|
|
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
private readonly agentConfigCache = new Map<
|
|
string,
|
|
FileCacheEntry<ScopeConfig>
|
|
>();
|
|
private readonly projectAgentConfigCache = new Map<
|
|
string,
|
|
FileCacheEntry<ScopeConfig>
|
|
>();
|
|
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
readonly string[]
|
|
> | null = null;
|
|
private accumulatedConfigIssues: string[] = [];
|
|
|
|
constructor(options: PolicyLoaderOptions = {}) {
|
|
this.globalConfigPath =
|
|
options.globalConfigPath ?? defaultGlobalConfigPath();
|
|
this.agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
this.projectAgentsDir = options.projectAgentsDir ?? null;
|
|
this.globalMcpConfigPath =
|
|
options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
|
|
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
? [
|
|
...new Set(
|
|
options.mcpServerNames
|
|
.map((name) => name.trim())
|
|
.filter((name) => name.length > 0),
|
|
),
|
|
]
|
|
: null;
|
|
}
|
|
|
|
// ── Config issue accumulation ────────────────────────────────────────
|
|
|
|
private accumulateConfigIssues(issues: string[]): void {
|
|
for (const issue of issues) {
|
|
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
this.accumulatedConfigIssues.push(issue);
|
|
}
|
|
}
|
|
}
|
|
|
|
getConfigIssues(): string[] {
|
|
return [...this.accumulatedConfigIssues];
|
|
}
|
|
|
|
// ── Scope loaders ────────────────────────────────────────────────────
|
|
|
|
loadGlobalConfig(): ScopeConfig {
|
|
const stamp = getFileStamp(this.globalConfigPath);
|
|
if (this.globalConfigCache?.stamp === stamp) {
|
|
return this.globalConfigCache.value;
|
|
}
|
|
|
|
const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
|
|
this.accumulateConfigIssues(issues);
|
|
|
|
const value: ScopeConfig = {
|
|
permission: config.permission,
|
|
};
|
|
|
|
this.globalConfigCache = { stamp, value };
|
|
return value;
|
|
}
|
|
|
|
|
|
private loadScopeConfigFrom(
|
|
dir: string | null,
|
|
cache: Map<string, FileCacheEntry<ScopeConfig>>,
|
|
agentName?: string,
|
|
): ScopeConfig {
|
|
if (!dir || !agentName) {
|
|
return {};
|
|
}
|
|
|
|
const filePath = join(dir, `${agentName}.md`);
|
|
const stamp = getFileStamp(filePath);
|
|
const cached = cache.get(agentName);
|
|
if (cached?.stamp === stamp) {
|
|
return cached.value;
|
|
}
|
|
|
|
// An absent file (stat failed) is a legitimately-empty scope, not invalid;
|
|
// only a present-but-unreadable file fails closed.
|
|
if (stamp === "missing") {
|
|
const value: ScopeConfig = {};
|
|
cache.set(agentName, { stamp, value });
|
|
return value;
|
|
}
|
|
|
|
let value: ScopeConfig;
|
|
try {
|
|
const markdown = readFileSync(filePath, "utf-8");
|
|
const frontmatter = extractFrontmatter(markdown);
|
|
if (!frontmatter) {
|
|
value = {};
|
|
} else {
|
|
// Agent frontmatter carries non-config keys (name, description, model,
|
|
// …) alongside `permission`, so it is not validated by the strict
|
|
// config-file schema; only the `permission` block is extracted, and its
|
|
// malformed entries are dropped tolerantly as before.
|
|
const parsed = parseSimpleYamlMap(frontmatter);
|
|
value = {
|
|
permission: normalizeFlatPermissionValue(parsed.permission),
|
|
};
|
|
}
|
|
} catch {
|
|
// The file exists (stat succeeded above) but could not be read or
|
|
// parsed — fail closed for this scope (#646).
|
|
value = { invalid: true };
|
|
}
|
|
|
|
cache.set(agentName, { stamp, value });
|
|
return value;
|
|
}
|
|
|
|
loadAgentConfig(agentName?: string): ScopeConfig {
|
|
return this.loadScopeConfigFrom(
|
|
this.agentsDir,
|
|
this.agentConfigCache,
|
|
agentName,
|
|
);
|
|
}
|
|
|
|
loadProjectAgentConfig(agentName?: string): ScopeConfig {
|
|
return this.loadScopeConfigFrom(
|
|
this.projectAgentsDir,
|
|
this.projectAgentConfigCache,
|
|
agentName,
|
|
);
|
|
}
|
|
|
|
// ── MCP server names ─────────────────────────────────────────────────
|
|
|
|
getConfiguredMcpServerNames(): readonly string[] {
|
|
if (this.configuredMcpServerNamesOverride) {
|
|
return this.configuredMcpServerNamesOverride;
|
|
}
|
|
|
|
const paths = [this.globalMcpConfigPath];
|
|
const stamp = paths
|
|
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
.join("|");
|
|
if (this.configuredMcpServerNamesCache?.stamp === stamp) {
|
|
return this.configuredMcpServerNamesCache.value;
|
|
}
|
|
|
|
const value = getConfiguredMcpServerNamesFromPaths(paths);
|
|
this.configuredMcpServerNamesCache = { stamp, value };
|
|
return value;
|
|
}
|
|
|
|
// ── Cache stamp ───────────────────────────────────────────────────────
|
|
|
|
getCacheStamp(agentName?: string): string {
|
|
const agentStamp = agentName
|
|
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
|
|
: "missing";
|
|
const projectAgentStamp =
|
|
this.projectAgentsDir && agentName
|
|
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
|
|
: "none";
|
|
|
|
return `${getFileStamp(this.globalConfigPath)}|${agentStamp}|${projectAgentStamp}`;
|
|
}
|
|
|
|
// ── Resolved paths ────────────────────────────────────────────────────
|
|
|
|
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
return {
|
|
globalConfigPath: this.globalConfigPath,
|
|
globalConfigExists: existsSync(this.globalConfigPath),
|
|
agentsDir: this.agentsDir,
|
|
agentsDirExists: existsSync(this.agentsDir),
|
|
projectAgentsDir: this.projectAgentsDir,
|
|
projectAgentsDirExists: this.projectAgentsDir
|
|
? existsSync(this.projectAgentsDir)
|
|
: false,
|
|
};
|
|
}
|
|
}
|