Files
my-pi/pi-permission-system/src/hard-guard-loader.ts
T

123 lines
4.2 KiB
TypeScript

import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizeFlatPermissionValue,
stripJsonComments,
} from "./config-loader";
import type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
import type { FlatPermissionConfig, PatternValue, ScopeConfig } from "./types";
interface HardGuardDocument {
version?: number;
permission?: unknown;
}
const EMPTY_SCOPE: ScopeConfig = {};
const DENY_ALL: ScopeConfig = { permission: { "*": "deny" } };
function isDenyValue(value: PatternValue): boolean {
return value === "deny" ||
(typeof value === "object" && value !== null && "action" in value && value.action === "deny");
}
function assertHardGuardValues(permission: FlatPermissionConfig): void {
const valid = (value: PatternValue): boolean => value === "allow" || isDenyValue(value);
for (const [surface, value] of Object.entries(permission)) {
if (typeof value === "string" || (typeof value === "object" && value !== null && "action" in value)) {
if (!valid(value as PatternValue)) {
throw new Error(`hard guard '${surface}' must be allow or deny`);
}
continue;
}
for (const [pattern, action] of Object.entries(value)) {
if (!valid(action)) {
throw new Error(`hard guard '${surface}.${pattern}' must be allow or deny`);
}
}
}
}
function readHardGuards(path: string): ScopeConfig {
const parsed = JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as HardGuardDocument;
if (parsed.version !== undefined && parsed.version !== 1) {
throw new Error(`unsupported hard guard version: ${String(parsed.version)}`);
}
const normalized = normalizeFlatPermissionValue(parsed.permission ?? {});
if (normalized === undefined) {
throw new Error("hard guard permission must be an object");
}
assertHardGuardValues(normalized);
return { permission: { "*": "allow", ...normalized } };
}
/**
* Global-only hard boundary: deny rules plus same-file allow exceptions. It keeps the last valid snapshot and
* falls back to the bundled defaults; if both are invalid it denies all.
*/
export class HardGuardPolicyLoader implements PolicyLoader {
private lastStamp = "";
private lastGood: ScopeConfig | undefined;
private issues: string[] = [];
constructor(
private readonly userPath: string,
private readonly fallbackPath = fileURLToPath(
new URL("../config/hard-guards.default.jsonc", import.meta.url),
),
) {}
loadGlobalConfig(): ScopeConfig {
const stamp = this.getCacheStamp();
if (stamp === this.lastStamp && this.lastGood !== undefined) return this.lastGood;
this.lastStamp = stamp;
this.issues = [];
for (const candidate of [this.userPath, this.fallbackPath]) {
if (!existsSync(candidate)) continue;
try {
const loaded = readHardGuards(candidate);
this.lastGood = loaded;
return loaded;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.issues.push(`Failed to load hard guards '${candidate}': ${message}`);
}
}
if (this.lastGood !== undefined) return this.lastGood;
this.issues.push("No valid hard guard configuration was available; denying all requests.");
return DENY_ALL;
}
loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
getConfiguredMcpServerNames(): readonly string[] { return []; }
getCacheStamp(): string {
return [this.userPath, this.fallbackPath]
.map((path) => {
try {
const stat = statSync(path);
return `${path}:${stat.mtimeMs}:${stat.size}`;
} catch {
return `${path}:missing`;
}
})
.join("|");
}
getConfigIssues(): string[] { return [...this.issues]; }
getResolvedPolicyPaths(): ResolvedPolicyPaths {
return {
globalConfigPath: this.userPath,
globalConfigExists: existsSync(this.userPath),
agentsDir: dirname(this.userPath),
agentsDirExists: existsSync(dirname(this.userPath)),
projectAgentsDir: null,
projectAgentsDirExists: false,
};
}
}