mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { normalize } from "node:path";
|
|
import type { ZodError } from "zod";
|
|
import {
|
|
getGlobalConfigPath,
|
|
getLegacyExtensionConfigPath,
|
|
getLegacyGlobalPolicyPath,
|
|
} from "./config-paths";
|
|
import {
|
|
type ShellToolsConfig,
|
|
type UnifiedPermissionConfig,
|
|
unifiedConfigSchema,
|
|
} from "./config-schema";
|
|
import { mergeFlatPermissions } from "./permission-merge";
|
|
import type { FlatPermissionConfig, PatternValue } from "./types";
|
|
import { isDenyWithReason, isPermissionState } from "./types";
|
|
|
|
// The unified config shape is derived from the zod schema (config-schema.ts,
|
|
// the single source of truth) and re-exported so existing importers keep their
|
|
// import path. All fields are optional so partial configs merge before
|
|
// defaults are applied downstream.
|
|
export type { ShellToolsConfig, UnifiedPermissionConfig };
|
|
|
|
export interface UnifiedConfigLoadResult {
|
|
config: UnifiedPermissionConfig;
|
|
issues: string[];
|
|
}
|
|
|
|
export function stripJsonComments(input: string): string {
|
|
let output = "";
|
|
let i = 0;
|
|
while (i < input.length) {
|
|
const char = input[i];
|
|
const next = input[i + 1] ?? "";
|
|
|
|
if (char === "/" && next === "/") {
|
|
const seg = consumeLineComment(input, i);
|
|
output += seg.output;
|
|
i = seg.nextIndex;
|
|
continue;
|
|
}
|
|
if (char === "/" && next === "*") {
|
|
const seg = consumeBlockComment(input, i);
|
|
output += seg.output;
|
|
i = seg.nextIndex;
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
const seg = consumeString(input, i);
|
|
output += seg.output;
|
|
i = seg.nextIndex;
|
|
continue;
|
|
}
|
|
|
|
output += char;
|
|
i++;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/** A consumed run of source: the text to emit and the index to resume scanning. */
|
|
interface ScanSegment {
|
|
output: string;
|
|
nextIndex: number;
|
|
}
|
|
|
|
/** Consume a `//` line comment starting at `start`; drop the body, keep the newline. */
|
|
function consumeLineComment(input: string, start: number): ScanSegment {
|
|
const newlineIndex = input.indexOf("\n", start);
|
|
if (newlineIndex === -1) return { output: "", nextIndex: input.length };
|
|
return { output: "\n", nextIndex: newlineIndex + 1 };
|
|
}
|
|
|
|
/** Consume a block comment starting at `start`; drop it entirely. */
|
|
function consumeBlockComment(input: string, start: number): ScanSegment {
|
|
const closeIndex = input.indexOf("*/", start + 2);
|
|
if (closeIndex === -1) return { output: "", nextIndex: input.length };
|
|
return { output: "", nextIndex: closeIndex + 2 };
|
|
}
|
|
|
|
/**
|
|
* Consume a string literal starting at the opening quote at `start`.
|
|
* Honors backslash escapes so an escaped quote does not close the literal.
|
|
* Emits the opening quote, body, and closing quote verbatim.
|
|
*/
|
|
function consumeString(input: string, start: number): ScanSegment {
|
|
const quote = input[start];
|
|
let output = quote;
|
|
let i = start + 1;
|
|
let escaping = false;
|
|
while (i < input.length) {
|
|
const char = input[i];
|
|
output += char;
|
|
i++;
|
|
if (escaping) {
|
|
escaping = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaping = true;
|
|
continue;
|
|
}
|
|
if (char === quote) break;
|
|
}
|
|
return { output, nextIndex: i };
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
|
|
* Accepts PermissionState strings and DenyWithReason objects inside pattern
|
|
* maps. Drops non-object top-level values, invalid PermissionState strings, and
|
|
* invalid action values inside object maps.
|
|
*/
|
|
export function normalizeFlatPermissionValue(
|
|
value: unknown,
|
|
): FlatPermissionConfig | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const normalized: FlatPermissionConfig = {};
|
|
let hasAny = false;
|
|
|
|
for (const [key, val] of Object.entries(record)) {
|
|
if (typeof val === "string") {
|
|
if (isPermissionState(val)) {
|
|
normalized[key] = val;
|
|
hasAny = true;
|
|
}
|
|
} else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
const map: Record<string, PatternValue> = {};
|
|
let mapHasAny = false;
|
|
for (const [pattern, action] of Object.entries(
|
|
val as Record<string, unknown>,
|
|
)) {
|
|
if (isDenyWithReason(action)) {
|
|
map[pattern] = action;
|
|
mapHasAny = true;
|
|
} else if (isPermissionState(action)) {
|
|
map[pattern] = action;
|
|
mapHasAny = true;
|
|
}
|
|
}
|
|
if (mapHasAny) {
|
|
normalized[key] = map;
|
|
hasAny = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return hasAny ? normalized : undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate raw parsed JSON against the config schema (the single source of
|
|
* truth in `config-schema.ts`).
|
|
*
|
|
* On success the typed config is returned. On failure the whole scope config is
|
|
* rejected — fail-closed: an empty config contributes no rules, so missing
|
|
* surfaces fall through to the universal `ask` default rather than `allow` —
|
|
* and every schema violation is reported as a clear, actionable issue.
|
|
*/
|
|
export function validateUnifiedConfig(
|
|
parsed: unknown,
|
|
): UnifiedConfigLoadResult {
|
|
const result = unifiedConfigSchema.safeParse(parsed);
|
|
if (result.success) {
|
|
return { config: result.data, issues: [] };
|
|
}
|
|
return { config: {}, issues: formatConfigIssues(result.error) };
|
|
}
|
|
|
|
/** Render each schema violation as a clear, path-qualified message. */
|
|
function formatConfigIssues(error: ZodError): string[] {
|
|
const messages: string[] = [];
|
|
for (const issue of error.issues) {
|
|
if (issue.code === "unrecognized_keys") {
|
|
for (const key of issue.keys) {
|
|
messages.push(`Unrecognized config key '${key}'.`);
|
|
}
|
|
continue;
|
|
}
|
|
const location =
|
|
issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
|
|
messages.push(`Invalid config value at '${location}': ${issue.message}`);
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Merge two unified configs.
|
|
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
|
|
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
|
* present in the override.
|
|
*/
|
|
// Scalar knobs merged by override-replaces-base; keep in sync with
|
|
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
|
|
// yoloMode, doublePressToConfirm).
|
|
export function mergeUnifiedConfigs(
|
|
base: UnifiedPermissionConfig,
|
|
override: UnifiedPermissionConfig,
|
|
): UnifiedPermissionConfig {
|
|
const merged: UnifiedPermissionConfig = {};
|
|
|
|
// Boolean scalars: override replaces base when defined
|
|
for (const key of [
|
|
"debugLog",
|
|
"permissionReviewLog",
|
|
"yoloMode",
|
|
"doublePressToConfirm",
|
|
] as const) {
|
|
const value = override[key] ?? base[key];
|
|
if (value !== undefined) {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
|
|
// Number scalars: override replaces base when defined
|
|
for (const key of [
|
|
"forwardingTimeoutMs",
|
|
"promptMaxRows",
|
|
"promptFieldMaxWidth",
|
|
"reviewLogFieldMaxWidth",
|
|
"toolInputPreviewMaxLength",
|
|
"toolTextSummaryMaxLength",
|
|
] as const) {
|
|
const value = override[key] ?? base[key];
|
|
if (value !== undefined) {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
|
|
// Array fields: override replaces base when defined
|
|
const authorizerChain = override.authorizerChain ?? base.authorizerChain;
|
|
if (authorizerChain !== undefined) {
|
|
merged.authorizerChain = authorizerChain;
|
|
}
|
|
|
|
// shellTools: merge by tool name, then by descriptor field. A project can
|
|
// replace argument names, but omitting a global decisionFloor must not silently
|
|
// remove that security boundary.
|
|
const baseShell = base.shellTools;
|
|
const overrideShell = override.shellTools;
|
|
if (baseShell || overrideShell) {
|
|
const toolNames = new Set([
|
|
...Object.keys(baseShell ?? {}),
|
|
...Object.keys(overrideShell ?? {}),
|
|
]);
|
|
merged.shellTools = Object.fromEntries(
|
|
[...toolNames].map((toolName) => [
|
|
toolName,
|
|
{ ...baseShell?.[toolName], ...overrideShell?.[toolName] },
|
|
]),
|
|
) as ShellToolsConfig;
|
|
}
|
|
|
|
// Built-in AutoReview and analyzer objects merge field-by-field so a project
|
|
// may narrow runtime settings without erasing global defaults.
|
|
for (const key of ["autoReview", "analysis"] as const) {
|
|
const baseValue = base[key];
|
|
const overrideValue = override[key];
|
|
if (baseValue !== undefined || overrideValue !== undefined) {
|
|
merged[key] = { ...baseValue, ...overrideValue };
|
|
}
|
|
}
|
|
|
|
// Permission: deep-shallow merge
|
|
const basePerm = base.permission;
|
|
const overridePerm = override.permission;
|
|
if (basePerm && overridePerm) {
|
|
merged.permission = mergeFlatPermissions(basePerm, overridePerm);
|
|
} else if (basePerm) {
|
|
merged.permission = basePerm;
|
|
} else if (overridePerm) {
|
|
merged.permission = overridePerm;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
export interface MergedConfigResult {
|
|
global: UnifiedPermissionConfig;
|
|
merged: UnifiedPermissionConfig;
|
|
issues: string[];
|
|
}
|
|
|
|
/**
|
|
* Load the user-global config, detect legacy global files, and collect issues.
|
|
* Project-level `policy.jsonc` files are intentionally not loaded.
|
|
*/
|
|
export function loadAndMergeConfigs(
|
|
agentDir: string,
|
|
_cwd: string,
|
|
extensionRoot: string,
|
|
): MergedConfigResult {
|
|
const allIssues: string[] = [];
|
|
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
|
|
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
|
|
let merged: UnifiedPermissionConfig = {};
|
|
|
|
if (existsSync(legacyGlobalPolicyPath)) {
|
|
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
|
|
allIssues.push(
|
|
`Legacy global policy found at '${legacyGlobalPolicyPath}'. ` +
|
|
`Move it to '${newGlobalPath}':\n` +
|
|
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
|
|
);
|
|
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
}
|
|
|
|
if (
|
|
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
existsSync(legacyExtConfigPath)
|
|
) {
|
|
const legacy = loadUnifiedConfig(legacyExtConfigPath);
|
|
allIssues.push(
|
|
`Legacy extension config found at '${legacyExtConfigPath}'. ` +
|
|
`Move it to '${newGlobalPath}':\n` +
|
|
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
|
|
);
|
|
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
}
|
|
|
|
const globalResult = loadUnifiedConfig(newGlobalPath);
|
|
allIssues.push(...globalResult.issues);
|
|
const globalConfig = globalResult.config;
|
|
merged = mergeUnifiedConfigs(merged, globalConfig);
|
|
|
|
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
|
|
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
|
|
|
|
const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
|
|
if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
|
|
|
|
return {
|
|
global: globalConfig,
|
|
merged,
|
|
issues: allIssues,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect the config footgun where a permissive top-level `*: allow` leaves the
|
|
* bash surface ungated, so every bash command silently inherits `allow`.
|
|
*
|
|
* Returns one warning string when `permission["*"] === "allow"` and the `bash`
|
|
* surface neither is a bare string (shorthand for `{ "*": … }`) nor an object
|
|
* map with an explicit `"*"` key. Returns `undefined` otherwise. The detector
|
|
* is pure: it takes the merged permission map and returns a message; the caller
|
|
* owns pushing it onto the issue list.
|
|
*/
|
|
export function detectPermissiveBashFallback(
|
|
permission: FlatPermissionConfig | undefined,
|
|
): string | undefined {
|
|
if (permission?.["*"] !== "allow") return undefined;
|
|
|
|
// The Record index signature reports an absent surface as the value type, not
|
|
// `undefined`; read through a Partial view so the absent-bash guard is honest
|
|
// (an unguarded Object.hasOwn(undefined, …) would throw at runtime).
|
|
const surfaces: Partial<FlatPermissionConfig> = permission;
|
|
const bash = surfaces.bash;
|
|
// A bare string surface is shorthand for `{ "*": action }` — explicitly gated.
|
|
if (typeof bash === "string") return undefined;
|
|
// An object map with an explicit `"*"` key is explicitly gated.
|
|
if (bash && Object.hasOwn(bash, "*")) return undefined;
|
|
|
|
return (
|
|
"Permission config sets a permissive top-level '*': 'allow' with no 'bash' '*' policy, " +
|
|
"so bash commands silently inherit 'allow'. Set an explicit 'bash' policy " +
|
|
'(e.g. "bash": { "*": "ask" }) to gate bash commands.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect a config still setting one of the two superseded tool-preview caps.
|
|
*
|
|
* `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` bounded one
|
|
* preview inside a prompt, never the prompt itself, which is why they never
|
|
* bounded it; `promptMaxRows` and `promptFieldMaxWidth` supersede them
|
|
* (ADR 0011 §5). Both stay valid in the schema so an existing config is not
|
|
* rejected fail-closed — they are simply no longer read.
|
|
*
|
|
* Pure, following `detectPermissiveBashFallback`: it takes the merged config
|
|
* and returns a message; the caller owns pushing it onto the issue list.
|
|
*/
|
|
export function detectDeprecatedPreviewCaps(
|
|
config: UnifiedPermissionConfig,
|
|
): string | undefined {
|
|
const set = (
|
|
["toolInputPreviewMaxLength", "toolTextSummaryMaxLength"] as const
|
|
).filter((key) => config[key] !== undefined);
|
|
if (set.length === 0) return undefined;
|
|
|
|
return (
|
|
`Permission config sets ${set.map((key) => `'${key}'`).join(" and ")}, ` +
|
|
"which is deprecated and ignored. The prompt is bounded by " +
|
|
"'promptMaxRows' and 'promptFieldMaxWidth' instead; remove the setting."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Load and normalize a unified config file.
|
|
* Returns an empty config with no issues if the file does not exist.
|
|
* Returns an empty config with an issue if the file cannot be parsed.
|
|
*/
|
|
export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
|
|
if (!existsSync(path)) {
|
|
return { config: {}, issues: [] };
|
|
}
|
|
|
|
try {
|
|
const raw = readFileSync(path, "utf-8");
|
|
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
return validateUnifiedConfig(parsed);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
config: {},
|
|
issues: [`Failed to read config at '${path}': ${message}`],
|
|
};
|
|
}
|
|
}
|