@ian-pascoe/pi-guardian 0.0.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.
@@ -0,0 +1,232 @@
1
+ import type {
2
+ AgentSession,
3
+ SessionManager,
4
+ SettingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ defineLayeredSettings,
8
+ type LayeredSettingChange,
9
+ type LayeredSettingsLayers,
10
+ } from "@ian-pascoe/pi-utils/layered-settings";
11
+ import { Type, type Static } from "typebox";
12
+ import { literalWords } from "./safe-command.js";
13
+
14
+ /** A Tool Policy: run without review, send to the Guardian, or block outright. */
15
+ export const toolPolicySchema = Type.Union([
16
+ Type.Literal("allow"),
17
+ Type.Literal("review"),
18
+ Type.Literal("deny"),
19
+ ]);
20
+ export type ToolPolicy = Static<typeof toolPolicySchema>;
21
+
22
+ export const thinkingLevelSchema = Type.Union([
23
+ Type.Literal("off"),
24
+ Type.Literal("minimal"),
25
+ Type.Literal("low"),
26
+ Type.Literal("medium"),
27
+ Type.Literal("high"),
28
+ Type.Literal("xhigh"),
29
+ Type.Literal("max"),
30
+ ]);
31
+ export type GuardianThinkingLevel = Static<typeof thinkingLevelSchema>;
32
+
33
+ const positiveInteger = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
34
+
35
+ /** Authored Guardian options; every key is optional so absent values inherit. */
36
+ export const guardianOptionsSchema = Type.Object(
37
+ {
38
+ enabled: Type.Optional(Type.Boolean()),
39
+ model: Type.Optional(Type.String({ minLength: 1 })),
40
+ thinkingLevel: Type.Optional(thinkingLevelSchema),
41
+ /** Per-tool Tool Policies; `null` resets an inherited entry to the built-in default. */
42
+ tools: Type.Optional(
43
+ Type.Record(Type.String({ minLength: 1 }), Type.Union([toolPolicySchema, Type.Null()])),
44
+ ),
45
+ /** Extra Safe Command prefixes, such as `npm test`; merged across scopes as a union. */
46
+ safeCommands: Type.Optional(
47
+ Type.Array(Type.String({ minLength: 1, pattern: "\\S" }), { uniqueItems: true }),
48
+ ),
49
+ /** Security Policy added to the Guardian's built-in policy. */
50
+ policy: Type.Optional(Type.String()),
51
+ reviewTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: 2_147_483_647 })),
52
+ evidenceBudgetTokens: Type.Optional(Type.Union([positiveInteger, Type.Literal("auto")])),
53
+ onDeny: Type.Optional(Type.Union([Type.Literal("block"), Type.Literal("ask")])),
54
+ maxConsecutiveRejections: Type.Optional(
55
+ Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
56
+ ),
57
+ /** Ask for a rationale on every review and show allowed reviews in the transcript. */
58
+ verbose: Type.Optional(Type.Boolean()),
59
+ },
60
+ { additionalProperties: false },
61
+ );
62
+
63
+ /** Authored options; absent values inherit rather than disabling their setting. */
64
+ export type GuardianOptions = Static<typeof guardianOptionsSchema>;
65
+ /** Fully defaulted settings; an absent model follows the Guarded Agent's current model. */
66
+ export interface GuardianConfig {
67
+ enabled: boolean;
68
+ model?: string;
69
+ thinkingLevel: GuardianThinkingLevel;
70
+ /** Effective Tool Policies after merging every scope's entries. */
71
+ tools: Record<string, ToolPolicy>;
72
+ safeCommands: string[];
73
+ policy: string;
74
+ reviewTimeoutMs: number;
75
+ evidenceBudgetTokens: number | "auto";
76
+ onDeny: "block" | "ask";
77
+ maxConsecutiveRejections: number;
78
+ verbose: boolean;
79
+ }
80
+
81
+ export const guardianSettingScopeSchema = Type.Union([
82
+ Type.Literal("session"),
83
+ Type.Literal("global"),
84
+ Type.Literal("project"),
85
+ ]);
86
+ export type GuardianSettingScope = Static<typeof guardianSettingScopeSchema>;
87
+ export const guardianSettingSourceSchema = Type.Union([
88
+ Type.Literal("default"),
89
+ guardianSettingScopeSchema,
90
+ ]);
91
+ export type GuardianSettingSource = Static<typeof guardianSettingSourceSchema>;
92
+
93
+ export const guardianDefaults: GuardianConfig = {
94
+ enabled: true,
95
+ thinkingLevel: "low",
96
+ tools: {},
97
+ safeCommands: [],
98
+ policy: "",
99
+ reviewTimeoutMs: 60_000,
100
+ evidenceBudgetTokens: "auto",
101
+ onDeny: "block",
102
+ maxConsecutiveRejections: 3,
103
+ verbose: false,
104
+ };
105
+
106
+ /** Pi's branch summarization uses the same fallback for models without a declared window. */
107
+ const fallbackWindow = 128_000;
108
+ const autoEvidenceCeiling = 32_000;
109
+
110
+ /**
111
+ * Evidence token budget. `auto` is a quarter of the Guardian model's context window, at most 32K:
112
+ * every Reviewed Call blocks the agent until its review ends, so evidence stays small.
113
+ */
114
+ export function evidenceBudget(
115
+ setting: GuardianConfig["evidenceBudgetTokens"],
116
+ contextWindow: number | undefined,
117
+ ): number {
118
+ if (setting !== "auto") return contextWindow ? Math.min(setting, contextWindow) : setting;
119
+ return Math.min(Math.floor((contextWindow || fallbackWindow) / 4), autoEvidenceCeiling);
120
+ }
121
+
122
+ const layered = defineLayeredSettings({
123
+ namespace: "guardian",
124
+ label: "Guardian",
125
+ schema: guardianOptionsSchema,
126
+ defaults: guardianDefaults,
127
+ sessionEntryType: "pi-guardian-settings",
128
+ merge: {
129
+ // Each scope adds or replaces entries; `null` drops the inherited entry.
130
+ tools: (current: GuardianConfig["tools"], next: NonNullable<GuardianOptions["tools"]>) => {
131
+ const merged = { ...current };
132
+ for (const [name, policy] of Object.entries(next)) {
133
+ if (policy === null) delete merged[name];
134
+ else merged[name] = policy;
135
+ }
136
+ return merged;
137
+ },
138
+ safeCommands: (current: string[], next: string[]) => [...new Set([...current, ...next])],
139
+ },
140
+ });
141
+
142
+ export const guardianOptionKeys = layered.optionKeys;
143
+
144
+ /**
145
+ * Reject `safeCommands` entries that could never match: an entry must be literal words, and its
146
+ * program a bare name, since a command run through a path (`./gradlew`) is never a Safe Command.
147
+ */
148
+ function checkSafeCommands(
149
+ options: GuardianOptions,
150
+ source: GuardianSettingScope,
151
+ ): GuardianOptions {
152
+ for (const [index, entry] of (options.safeCommands ?? []).entries()) {
153
+ const program = literalWords(entry)?.[0];
154
+ const problem =
155
+ program === undefined
156
+ ? "must be literal words without shell syntax"
157
+ : /[/\\]/.test(program)
158
+ ? "must start with a bare program name, not a path"
159
+ : undefined;
160
+ if (problem)
161
+ throw new Error(
162
+ `Invalid ${source} Guardian settings/safeCommands/${index}: Safe Command ${JSON.stringify(entry)} ${problem}`,
163
+ );
164
+ }
165
+ return options;
166
+ }
167
+
168
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Native settings and command input contain arbitrary authored JSON; the shared layered-settings schema check validates it before use.
169
+ export function parseGuardianOptions(value: unknown, source: GuardianSettingScope) {
170
+ return checkSafeCommands(layered.parseOptions(value, source), source);
171
+ }
172
+
173
+ /** Reject inherited or unknown property names before applying an authored change. */
174
+ export function guardianOptionKey(input: string): keyof GuardianOptions {
175
+ return layered.optionKey(input);
176
+ }
177
+
178
+ /** Replay only the selected branch's last complete override snapshot. */
179
+ export function readGuardianOverrides(manager: Pick<SessionManager, "getBranch">): GuardianOptions {
180
+ return layered.readOverrides(manager);
181
+ }
182
+
183
+ /** Authored scopes cached at startup/reload and updated after this extension's own writes. */
184
+ export type GuardianLayers = LayeredSettingsLayers<GuardianOptions>;
185
+ /** A validated authored mutation, independent of its persistence scope. */
186
+ export type GuardianChange = LayeredSettingChange<GuardianOptions>;
187
+ /** An applied change as recorded for confirmation; `options` is empty when the key inherits. */
188
+ export const guardianAppliedChangeSchema = Type.Object({
189
+ scope: guardianSettingScopeSchema,
190
+ key: Type.KeyOf(guardianOptionsSchema),
191
+ options: guardianOptionsSchema,
192
+ });
193
+ export type GuardianAppliedChange = Static<typeof guardianAppliedChangeSchema>;
194
+
195
+ /** Read Pi's stored layers, preserving configuration failures until corrected. */
196
+ export function readGuardianLayers(manager: SettingsManager): GuardianLayers {
197
+ const layers = layered.readLayers(manager);
198
+ for (const scope of ["global", "project"] as const) {
199
+ const layer = layers[scope];
200
+ if (layer instanceof Error) continue;
201
+ try {
202
+ checkSafeCommands(layer, scope);
203
+ } catch (cause) {
204
+ layers[scope] = cause instanceof Error ? cause : new Error(String(cause));
205
+ }
206
+ }
207
+ return layers;
208
+ }
209
+
210
+ /** Effective settings with the scope that supplied each key. */
211
+ export interface ResolvedGuardianSettings {
212
+ settings: GuardianConfig;
213
+ sources: Record<string, GuardianSettingSource>;
214
+ }
215
+
216
+ /** Resolve stored scopes: default < global < trusted project < session. */
217
+ export function readGuardianSettings(
218
+ session: Pick<AgentSession, "settingsManager" | "sessionManager">,
219
+ authored = readGuardianLayers(session.settingsManager),
220
+ ): ResolvedGuardianSettings {
221
+ return layered.readSettings(session, authored);
222
+ }
223
+
224
+ /** Write one change to Pi's global or trusted project settings document. */
225
+ export function writeGuardianSettings(
226
+ manager: SettingsManager,
227
+ scope: "global" | "project",
228
+ change: GuardianChange,
229
+ isCurrent: () => boolean,
230
+ ): Promise<GuardianOptions | undefined> {
231
+ return layered.writeSettings(manager, scope, change, isCurrent);
232
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./guardian-extension.js";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Safe Command recognition for `bash`. Deliberately conservative: anything this module cannot
3
+ * read as one simple command of literal words is not a Safe Command and goes to the Guardian.
4
+ */
5
+
6
+ /**
7
+ * Characters that make a command more than one simple command of literal words: pipes,
8
+ * redirection, chaining, background jobs, subshells, grouping, command/process substitution,
9
+ * variable/arithmetic/brace/history expansion, globbing, comments, and escapes.
10
+ */
11
+ const shellSyntax = /[|&;<>()$`\\*?[\]{}!#^]/;
12
+ /** Tilde expansion: `~` starting a word or following `=` or `:` (`HEAD~1` stays literal). */
13
+ const tildeExpansion = /(?:^|[\s=:])~/;
14
+ // oxlint-disable-next-line no-control-regex -- Control characters (including newlines) are exactly what this rejects.
15
+ const controlCharacters = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u2028\u2029]/;
16
+
17
+ /** Split a command into literal words; `undefined` when it is not one simple command. */
18
+ export function literalWords(command: string): string[] | undefined {
19
+ if (controlCharacters.test(command) || shellSyntax.test(command) || tildeExpansion.test(command))
20
+ return undefined;
21
+ const words: string[] = [];
22
+ let word: string | undefined;
23
+ let quote: "'" | '"' | undefined;
24
+ for (const character of command) {
25
+ if (quote) {
26
+ if (character === quote) quote = undefined;
27
+ else word = (word ?? "") + character;
28
+ continue;
29
+ }
30
+ if (character === "'" || character === '"') {
31
+ quote = character;
32
+ word ??= "";
33
+ continue;
34
+ }
35
+ if (character === " " || character === "\t") {
36
+ if (word !== undefined) words.push(word);
37
+ word = undefined;
38
+ continue;
39
+ }
40
+ word = (word ?? "") + character;
41
+ }
42
+ if (quote) return undefined;
43
+ if (word !== undefined) words.push(word);
44
+ return words.length ? words : undefined;
45
+ }
46
+
47
+ /** Validates a built-in safe program's arguments; `true` when they cannot cause side effects. */
48
+ type ArgumentCheck = (args: readonly string[]) => boolean;
49
+
50
+ const anyArguments: ArgumentCheck = () => true;
51
+ const rejectOptions =
52
+ (...forbidden: string[]): ArgumentCheck =>
53
+ (args) =>
54
+ !args.some((arg) => forbidden.some((option) => arg === option || arg.startsWith(`${option}=`)));
55
+
56
+ /** `find` actions that execute programs, delete files, or write output files. */
57
+ const findActions = new Set([
58
+ "-exec",
59
+ "-execdir",
60
+ "-ok",
61
+ "-okdir",
62
+ "-delete",
63
+ "-fprint",
64
+ "-fprint0",
65
+ "-fprintf",
66
+ "-fls",
67
+ ]);
68
+
69
+ /** `git log`, `diff` and `show` options that write files or run configured external programs. */
70
+ const gitOutputOptions = rejectOptions("--output", "--ext-diff", "--textconv");
71
+ /** `git branch` options that only list branches; any other word could create or delete one. */
72
+ const gitBranchListing = new Set([
73
+ "-a",
74
+ "-r",
75
+ "-v",
76
+ "-vv",
77
+ "-l",
78
+ "--list",
79
+ "--all",
80
+ "--remotes",
81
+ "--verbose",
82
+ "--show-current",
83
+ "--no-color",
84
+ "--color",
85
+ ]);
86
+ const gitSubcommands = new Map<string, ArgumentCheck>([
87
+ ["status", anyArguments],
88
+ ["log", gitOutputOptions],
89
+ ["diff", gitOutputOptions],
90
+ ["show", gitOutputOptions],
91
+ ["branch", (args) => args.every((arg) => gitBranchListing.has(arg))],
92
+ ["rev-parse", anyArguments],
93
+ ]);
94
+
95
+ /** Built-in safe programs and their argument checks. */
96
+ const builtInPrograms = new Map<string, ArgumentCheck>([
97
+ ["ls", anyArguments],
98
+ ["pwd", anyArguments],
99
+ ["cat", anyArguments],
100
+ ["head", anyArguments],
101
+ ["tail", anyArguments],
102
+ ["wc", anyArguments],
103
+ ["echo", anyArguments],
104
+ ["stat", anyArguments],
105
+ ["du", anyArguments],
106
+ ["df", anyArguments],
107
+ ["basename", anyArguments],
108
+ ["dirname", anyArguments],
109
+ ["realpath", anyArguments],
110
+ ["which", anyArguments],
111
+ ["whoami", anyArguments],
112
+ ["uname", anyArguments],
113
+ ["grep", anyArguments],
114
+ // `--pre` and `--hostname-bin` run arbitrary programs.
115
+ ["rg", rejectOptions("--pre", "--pre-glob", "--hostname-bin")],
116
+ ["find", (args) => !args.some((arg) => findActions.has(arg))],
117
+ // Global options such as `-c core.pager=…` or `-C dir` must not precede the subcommand.
118
+ [
119
+ "git",
120
+ ([subcommand, ...args]) => {
121
+ const check = subcommand === undefined ? undefined : gitSubcommands.get(subcommand);
122
+ return check ? check(args) : false;
123
+ },
124
+ ],
125
+ ]);
126
+
127
+ /** The built-in safe program names, for documentation and status. */
128
+ export const builtInSafePrograms: readonly string[] = [...builtInPrograms.keys()];
129
+
130
+ /**
131
+ * Whether `command` is a Safe Command: one simple command of literal words whose program is a
132
+ * built-in safe program with side-effect-free arguments, or whose leading words match one of the
133
+ * configured `safeCommands` prefixes (for example `npm test`).
134
+ */
135
+ export function isSafeCommand(command: string, configured: readonly string[] = []): boolean {
136
+ const words = literalWords(command);
137
+ const program = words?.[0];
138
+ if (!words || program === undefined) return false;
139
+ // An environment assignment (`PAGER=x git log`) or a path (`./ls`) is not a known program.
140
+ if (program.includes("=") || program.includes("/") || program === "") return false;
141
+ for (const entry of configured) {
142
+ const prefix = literalWords(entry);
143
+ if (prefix?.length && prefix.every((word, index) => words[index] === word)) return true;
144
+ }
145
+ const check = builtInPrograms.get(program);
146
+ return check ? check(words.slice(1)) : false;
147
+ }
@@ -0,0 +1,205 @@
1
+ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /** Where Sensitive Paths are judged from. */
7
+ export interface SensitivePathContext {
8
+ /** Workspace root: the Guarded Agent's working directory. */
9
+ cwd: string;
10
+ /** Pi configuration and session locations, sensitive wherever they are. */
11
+ piDirectories: readonly string[];
12
+ /**
13
+ * Files and directories of resources Pi loaded into the Guarded Agent (context files, Skills,
14
+ * prompt templates, extensions), sensitive wherever they are: they are trusted instructions
15
+ * or code.
16
+ */
17
+ loadedResources?: readonly string[];
18
+ /** The user's home directory; defaults to `os.homedir()`. */
19
+ home?: string;
20
+ /** Defaults to `process.platform`. */
21
+ platform?: NodeJS.Platform;
22
+ }
23
+
24
+ // Pi's own path normalization replaces these with ordinary spaces before resolving.
25
+ const unicodeSpaces = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
26
+
27
+ /** Resolve a tool path argument the way Pi's file tools do, without touching the file system. */
28
+ export function resolveToolPath(input: string, cwd: string): string {
29
+ let normalized = input.replace(unicodeSpaces, " ");
30
+ if (normalized.startsWith("@")) normalized = normalized.slice(1);
31
+ if (normalized === "~") normalized = homedir();
32
+ else if (normalized.startsWith("~/")) normalized = join(homedir(), normalized.slice(2));
33
+ else if (normalized.startsWith("file://")) normalized = fileURLToPath(normalized);
34
+ return isAbsolute(normalized) ? resolve(normalized) : resolve(cwd, normalized);
35
+ }
36
+
37
+ /**
38
+ * Resolve symlinks in the longest existing ancestor; the missing remainder stays lexical. A
39
+ * dangling symlink is followed to its target, so a link to a missing `.git` still counts.
40
+ */
41
+ function realPath(path: string, depth = 0): string {
42
+ const missing: string[] = [];
43
+ let current = path;
44
+ for (;;) {
45
+ try {
46
+ return join(realpathSync.native(current), ...missing.toReversed());
47
+ } catch {
48
+ // Not resolvable as a whole; try a dangling link, then the parent.
49
+ }
50
+ try {
51
+ if (depth < 32 && lstatSync(current).isSymbolicLink()) {
52
+ const target = resolve(dirname(current), readlinkSync(current));
53
+ return realPath(join(target, ...missing.toReversed()), depth + 1);
54
+ }
55
+ } catch {
56
+ // Missing component: keep walking up.
57
+ }
58
+ const parent = dirname(current);
59
+ if (parent === current) return path;
60
+ missing.push(basename(current));
61
+ current = parent;
62
+ }
63
+ }
64
+
65
+ /** `path`'s components below `root`, or `undefined` when it lies outside it. */
66
+ function within(path: string, root: string): string[] | undefined {
67
+ const relation = relative(root, path);
68
+ if (relation === "") return [];
69
+ if (relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation))
70
+ return undefined;
71
+ return relation.split(sep);
72
+ }
73
+
74
+ /** Context files Pi loads from the working directory and its ancestors, in any case. */
75
+ const contextFileNames = new Set(["agents.md", "agents.override.md", "claude.md"]);
76
+
77
+ /**
78
+ * Locations under the home directory where a change persists beyond the session: shell startup
79
+ * files, credentials, user configuration, and programs on `PATH`. Judged wherever the workspace is.
80
+ */
81
+ const homePersistence = new Set([
82
+ ".bashrc",
83
+ ".bash_profile",
84
+ ".bash_login",
85
+ ".bash_logout",
86
+ ".profile",
87
+ ".zshrc",
88
+ ".zprofile",
89
+ ".zshenv",
90
+ ".zlogin",
91
+ ".zlogout",
92
+ ".ssh",
93
+ ".gnupg",
94
+ ".aws",
95
+ ".azure",
96
+ ".config",
97
+ ".gitconfig",
98
+ ".git-credentials",
99
+ ".npmrc",
100
+ ".yarnrc",
101
+ ".pypirc",
102
+ ".netrc",
103
+ ".docker",
104
+ ".kube",
105
+ ]);
106
+
107
+ /** Why a path below the home directory is a persistence or credential location. */
108
+ function homeLocation(components: readonly string[]): string | undefined {
109
+ const [first = "", second = ""] = components.map((component) => component.toLowerCase());
110
+ const persistent =
111
+ homePersistence.has(first) ||
112
+ (first === ".local" && second === "bin") ||
113
+ (first === "library" && second === "launchagents");
114
+ return persistent
115
+ ? `a shell startup, credential, or persistence location in the home directory (${components.slice(0, first === ".local" || first === "library" ? 2 : 1).join("/")})`
116
+ : undefined;
117
+ }
118
+
119
+ /**
120
+ * Components inside the workspace whose change can weaken Guardian, expose secrets, alter
121
+ * trusted instructions, or run code later: version control, secrets, Pi and agent
122
+ * configuration, context files, git hooks, CI workflows, and editor tasks.
123
+ */
124
+ function sensitiveComponent(components: readonly string[]): string | undefined {
125
+ for (const [index, component] of components.entries()) {
126
+ const name = component.toLowerCase();
127
+ if (name === ".git") return "version-control metadata (.git)";
128
+ if (name === ".pi") return "Pi configuration (.pi)";
129
+ if (name === ".agents") return "agent Skills and configuration (.agents)";
130
+ if (name.startsWith(".env")) return `a secret or environment file (${component})`;
131
+ if (name === ".husky") return "git hooks (.husky)";
132
+ if (name === ".vscode") return "editor tasks and settings (.vscode)";
133
+ if (name === ".github" && components[index + 1]?.toLowerCase() === "workflows")
134
+ return "CI workflows (.github/workflows)";
135
+ }
136
+ const last = components.at(-1)?.toLowerCase();
137
+ if (last && contextFileNames.has(last))
138
+ return `a context file Pi loads as instructions (${components.at(-1)})`;
139
+ return undefined;
140
+ }
141
+
142
+ /** A path in every spelling: as given and with symlinks resolved. */
143
+ interface Spelled {
144
+ roots: readonly string[];
145
+ piDirectories: readonly string[];
146
+ resources: readonly string[];
147
+ homes: readonly string[];
148
+ }
149
+
150
+ /** Why `path` is sensitive. */
151
+ function judge(path: string, where: Spelled): string | undefined {
152
+ if (where.piDirectories.some((directory) => within(path, directory)))
153
+ return "Pi's agent configuration or session files";
154
+ if (where.resources.some((resource) => within(path, resource)))
155
+ return "a context file, Skill, prompt template, or extension Pi loaded";
156
+ if (where.homes.some((home) => where.roots.some((root) => within(home, root))))
157
+ return "the workspace root contains the home directory, so every file is reviewed";
158
+ for (const home of where.homes) {
159
+ const below = within(path, home);
160
+ const reason = below && homeLocation(below);
161
+ if (reason) return reason;
162
+ }
163
+ const components = where.roots
164
+ .map((root) => within(path, root))
165
+ .find((found) => found !== undefined);
166
+ if (!components) return "outside the workspace root";
167
+ return sensitiveComponent(components);
168
+ }
169
+
170
+ /** A file reachable through more than one hard link: editing it in place changes the others. */
171
+ function hardLinked(path: string): boolean {
172
+ try {
173
+ const stats = statSync(path);
174
+ return stats.isFile() && stats.nlink > 1;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /** Windows path forms (backslashes, drive letters, UNC) that Guardian does not judge. */
181
+ const windowsPathForm = /\\|^[A-Za-z]:/;
182
+
183
+ /**
184
+ * Why modifying `input` is sensitive, or `undefined` for an ordinary workspace path. Judged on
185
+ * both the lexical path and the path with symlinks resolved; either being sensitive is enough.
186
+ */
187
+ export function sensitivePathReason(
188
+ input: string,
189
+ context: SensitivePathContext,
190
+ ): string | undefined {
191
+ if ((context.platform ?? process.platform) === "win32" && windowsPathForm.test(input))
192
+ return "a Windows path form Guardian does not judge";
193
+ const lexical = resolveToolPath(input, context.cwd);
194
+ const spellings = (path: string) => [...new Set([resolve(path), realPath(resolve(path))])];
195
+ const where: Spelled = {
196
+ roots: spellings(context.cwd),
197
+ piDirectories: context.piDirectories.flatMap(spellings),
198
+ resources: (context.loadedResources ?? []).flatMap(spellings),
199
+ homes: spellings(context.home ?? homedir()),
200
+ };
201
+ const targets = spellings(lexical);
202
+ if (targets.some(hardLinked))
203
+ return "a file with more than one hard link, so editing it changes another path too";
204
+ return targets.map((path) => judge(path, where)).find((reason) => reason !== undefined);
205
+ }
@@ -0,0 +1,94 @@
1
+ import type { CustomToolCallEvent, ToolAnnotations } from "@earendil-works/pi-coding-agent";
2
+ import { isSafeCommand } from "./safe-command.js";
3
+ import { sensitivePathReason, type SensitivePathContext } from "./sensitive-paths.js";
4
+ import type { ToolPolicy } from "./guardian-settings.js";
5
+
6
+ /** Which rule supplied a call's Tool Policy, in precedence order. */
7
+ export type ToolPolicySource = "setting" | "default" | "annotation" | "fallback";
8
+
9
+ /** A Tool Policy resolved for one call, with why. */
10
+ export interface ResolvedToolPolicy {
11
+ policy: ToolPolicy;
12
+ source: ToolPolicySource;
13
+ /** Why a built-in default sends this particular call to review. */
14
+ detail?: string;
15
+ }
16
+
17
+ /** Tools whose calls run without review by default. */
18
+ export const allowedByDefault: readonly string[] = [
19
+ "read",
20
+ "grep",
21
+ "find",
22
+ "ls",
23
+ "codemode",
24
+ "tool_search",
25
+ "todo",
26
+ "web_search",
27
+ // Context Management: session-local notes, journal reads, and handoffs.
28
+ "context_notes",
29
+ "context_history",
30
+ "context_rollover",
31
+ ];
32
+ /** Tools reviewed by default regardless of their annotations. */
33
+ export const reviewedByDefault: readonly string[] = [
34
+ "terminal_start",
35
+ "terminal_send",
36
+ "powershell",
37
+ ];
38
+
39
+ /** Everything Tool Policy resolution reads for one call. */
40
+ export interface ToolPolicyInput {
41
+ toolName: string;
42
+ /** The call's arguments after earlier `tool_call` handlers ran. */
43
+ input: CustomToolCallEvent["input"];
44
+ /** Configured Tool Policies (`tools` setting), merged across scopes. */
45
+ configured: Readonly<Record<string, ToolPolicy>>;
46
+ /** Configured Safe Command prefixes (`safeCommands` setting). */
47
+ safeCommands: readonly string[];
48
+ /** The tool's author-supplied annotations, if any. */
49
+ annotations: ToolAnnotations | undefined;
50
+ paths: SensitivePathContext;
51
+ }
52
+
53
+ /** Built-in default Tool Policy for one call, or `undefined` when the tool has none. */
54
+ function builtInDefault(call: ToolPolicyInput): ResolvedToolPolicy | undefined {
55
+ const { toolName, input } = call;
56
+ if (allowedByDefault.includes(toolName)) return { policy: "allow", source: "default" };
57
+ if (reviewedByDefault.includes(toolName)) return { policy: "review", source: "default" };
58
+ if (toolName === "edit" || toolName === "write") {
59
+ const path = input["path"];
60
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: tool arguments are model-supplied JSON; a non-string path cannot be judged and is reviewed.
61
+ if (typeof path !== "string")
62
+ return { policy: "review", source: "default", detail: "the target path is not a string" };
63
+ const reason = sensitivePathReason(path, call.paths);
64
+ return reason
65
+ ? { policy: "review", source: "default", detail: `Sensitive Path: ${reason}` }
66
+ : { policy: "allow", source: "default" };
67
+ }
68
+ if (toolName === "bash") {
69
+ const command = input["command"];
70
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: tool arguments are model-supplied JSON; a non-string command cannot be judged and is reviewed.
71
+ if (typeof command === "string" && isSafeCommand(command, call.safeCommands))
72
+ return { policy: "allow", source: "default" };
73
+ return { policy: "review", source: "default", detail: "not a Safe Command" };
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * Resolve one call's Tool Policy: the configured `tools` entry, else the built-in default
80
+ * (including Safe Command and Sensitive Path exemptions), else a `readOnlyHint` annotation without
81
+ * `openWorldHint`, else review.
82
+ */
83
+ export function resolveToolPolicy(call: ToolPolicyInput): ResolvedToolPolicy {
84
+ const configured = Object.hasOwn(call.configured, call.toolName)
85
+ ? call.configured[call.toolName]
86
+ : undefined;
87
+ if (configured) return { policy: configured, source: "setting" };
88
+ const fallback = builtInDefault(call);
89
+ if (fallback) return fallback;
90
+ // A read-only tool that reaches the open world, such as a URL fetch, can still send data out.
91
+ if (call.annotations?.readOnlyHint === true && call.annotations.openWorldHint !== true)
92
+ return { policy: "allow", source: "annotation" };
93
+ return { policy: "review", source: "fallback" };
94
+ }