pi-ward 0.0.1 → 0.1.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.
- package/README.md +56 -0
- package/package.json +4 -1
- package/src/config.ts +169 -0
- package/src/evaluator.ts +50 -0
- package/src/guard.ts +48 -0
- package/src/index.ts +72 -3
- package/src/matcher.ts +72 -0
- package/src/pattern.ts +118 -0
- package/src/resolve.ts +68 -0
- package/src/rules.ts +19 -0
- package/src/self-protect.ts +57 -0
- package/src/tools/delete.ts +31 -0
- package/src/walk.ts +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pi-ward
|
|
2
|
+
|
|
3
|
+
File access guard for [pi](https://github.com/earendil-works/pi). Declarative filesystem boundaries for the coding agent.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-ward
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or try without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi -e npm:pi-ward
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
- Prevents the agent from reading secrets (`.env`, `.pem`, credentials)
|
|
20
|
+
- Blocks writes outside the project tree
|
|
21
|
+
- Stops accidental modification of `.git` internals
|
|
22
|
+
- Symlink-aware — no escape via symlink tricks
|
|
23
|
+
- Config-file self-protection — the agent can't weaken its own constraints
|
|
24
|
+
- Fail-closed on any error — never fails open
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
pi-ward intercepts file operations (`read`, `write`, `edit`) before they execute. It evaluates declarative rules from ward config files against the resolved real path.
|
|
29
|
+
|
|
30
|
+
**Baseline policy** (no config needed):
|
|
31
|
+
|
|
32
|
+
- Read/write within project root: allowed
|
|
33
|
+
- Any access outside project root: denied
|
|
34
|
+
|
|
35
|
+
## Config
|
|
36
|
+
|
|
37
|
+
Rules are loaded from `~/.pi/agent/ward.json` (global) and `.pi/ward.json` files along the directory tree. Global rules always take precedence — inner configs can't weaken outer ones.
|
|
38
|
+
|
|
39
|
+
Example `.pi/ward.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"rules": [
|
|
44
|
+
{ "pattern": ".env*", "effect": "deny" },
|
|
45
|
+
{ "pattern": "*.pem", "effect": "deny" },
|
|
46
|
+
{ "pattern": ".git/", "operations": ["write"], "effect": "deny" },
|
|
47
|
+
{ "pattern": ".secret/", "effect": "deny" }
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Rules are evaluated top-to-bottom, first match wins. See [DESIGN.md](./DESIGN.md) for pattern syntax, trust scoping, and the full specification.
|
|
53
|
+
|
|
54
|
+
## Design
|
|
55
|
+
|
|
56
|
+
See [DESIGN.md](./DESIGN.md) for the full specification.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ward",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"packageManager": "npm@11.12.1",
|
|
5
5
|
"description": "File access guard for the pi coding agent — declarative filesystem boundaries.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"format": "biome format --write",
|
|
36
36
|
"test": "vitest run"
|
|
37
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"typebox": "^1.1.38"
|
|
40
|
+
},
|
|
38
41
|
"peerDependencies": {
|
|
39
42
|
"@earendil-works/pi-coding-agent": ">=0.74.0"
|
|
40
43
|
},
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { parsePattern } from "./pattern.js";
|
|
6
|
+
import type { ParsedRule, Rule } from "./rules.js";
|
|
7
|
+
import { ancestorDirs } from "./walk.js";
|
|
8
|
+
|
|
9
|
+
export interface WardConfig {
|
|
10
|
+
rules: Rule[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LoadResult {
|
|
14
|
+
/** Flat list of parsed rules, global first, project last. */
|
|
15
|
+
rules: ParsedRule[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read and JSON-parse a config file.
|
|
20
|
+
* Returns null on ENOENT. Throws on EACCES or other read errors.
|
|
21
|
+
* Throws on invalid JSON.
|
|
22
|
+
*/
|
|
23
|
+
async function readConfigFile(filePath: string): Promise<WardConfig | null> {
|
|
24
|
+
let content: string;
|
|
25
|
+
try {
|
|
26
|
+
content = await readFile(filePath, "utf-8");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const code = (err as { code?: string }).code;
|
|
29
|
+
if (code === "ENOENT") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Cannot read config file "${filePath}": ${(err as Error).message}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let raw: unknown;
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(content);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`Invalid JSON in config file "${filePath}": ${(err as Error).message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return validateConfig(raw, filePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate the raw parsed JSON against the expected WardConfig schema.
|
|
47
|
+
* Throws with a descriptive message (including file path) on any schema violation.
|
|
48
|
+
*/
|
|
49
|
+
function validateConfig(raw: unknown, filePath: string): WardConfig {
|
|
50
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
51
|
+
throw new Error(`Config "${filePath}": must be a JSON object`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const obj = raw as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
if (!Array.isArray(obj.rules)) {
|
|
57
|
+
throw new Error(`Config "${filePath}": missing or invalid "rules" array`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rules = obj.rules.map((ruleRaw: unknown, i: number) => validateRule(ruleRaw, i, filePath));
|
|
61
|
+
return { rules };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateOperations(operations: unknown, index: number, filePath: string): ("read" | "write")[] {
|
|
65
|
+
if (!Array.isArray(operations)) {
|
|
66
|
+
throw new Error(`Config "${filePath}": rule[${index}].operations must be an array`);
|
|
67
|
+
}
|
|
68
|
+
for (const op of operations) {
|
|
69
|
+
if (op !== "read" && op !== "write") {
|
|
70
|
+
throw new Error(`Config "${filePath}": rule[${index}].operations contains invalid value "${String(op)}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return operations as ("read" | "write")[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateRule(ruleRaw: unknown, index: number, filePath: string): Rule {
|
|
77
|
+
if (typeof ruleRaw !== "object" || ruleRaw === null || Array.isArray(ruleRaw)) {
|
|
78
|
+
throw new Error(`Config "${filePath}": rule[${index}] must be an object`);
|
|
79
|
+
}
|
|
80
|
+
const r = ruleRaw as Record<string, unknown>;
|
|
81
|
+
|
|
82
|
+
if (typeof r.pattern !== "string") {
|
|
83
|
+
throw new Error(`Config "${filePath}": rule[${index}].pattern must be a string`);
|
|
84
|
+
}
|
|
85
|
+
if (r.effect !== "allow" && r.effect !== "deny") {
|
|
86
|
+
throw new Error(`Config "${filePath}": rule[${index}].effect must be "allow" or "deny"`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let operations: ("read" | "write")[] | undefined;
|
|
90
|
+
if (r.operations !== undefined) {
|
|
91
|
+
operations = validateOperations(r.operations, index, filePath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
pattern: r.pattern,
|
|
96
|
+
effect: r.effect,
|
|
97
|
+
...(operations !== undefined ? { operations } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse validated rules into ParsedRules, attaching configDir and checking
|
|
103
|
+
* for allow rules that can structurally never match within the config's directory.
|
|
104
|
+
*
|
|
105
|
+
* Throws if:
|
|
106
|
+
* - parsePattern throws (invalid syntax)
|
|
107
|
+
* - An allow rule has an anchored pattern whose first segment is ".." (escapes config dir)
|
|
108
|
+
*/
|
|
109
|
+
function parseConfigRules(config: WardConfig, configDir: string, filePath: string): ParsedRule[] {
|
|
110
|
+
return config.rules.map((rule, i) => {
|
|
111
|
+
const parsedPattern = parsePattern(rule.pattern);
|
|
112
|
+
|
|
113
|
+
// Load-time trust check: an anchored allow pattern starting with ".." can never
|
|
114
|
+
// match within the config's directory.
|
|
115
|
+
if (rule.effect === "allow" && parsedPattern.anchored && parsedPattern.segments.length > 0) {
|
|
116
|
+
const firstSeg = parsedPattern.segments[0];
|
|
117
|
+
if (firstSeg.kind === "literal" && firstSeg.value === "..") {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Config "${filePath}": rule[${i}]: allow rule with pattern "${rule.pattern}" ` +
|
|
120
|
+
`can never match within the config directory "${configDir}"`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
pattern: parsedPattern,
|
|
127
|
+
operations: rule.operations ?? ["read", "write"],
|
|
128
|
+
effect: rule.effect,
|
|
129
|
+
configDir,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Load all ward configs for a given project root.
|
|
136
|
+
*
|
|
137
|
+
* Walk order (global first, project last):
|
|
138
|
+
* 1. `~/.pi/agent/ward.json` — global config, configDir = homedir
|
|
139
|
+
* 2. `<dir>/.pi/ward.json` for each ancestor from homedir toward projectRoot
|
|
140
|
+
* 3. `projectRoot/.pi/ward.json` — project config, configDir = projectRoot
|
|
141
|
+
*
|
|
142
|
+
* If projectRoot is outside homedir, only the global config is loaded.
|
|
143
|
+
*
|
|
144
|
+
* ENOENT on any config file is silently skipped. Any other error fails closed.
|
|
145
|
+
*/
|
|
146
|
+
export async function loadConfig(projectRoot: string, homeDir?: string): Promise<LoadResult> {
|
|
147
|
+
const home = homeDir ?? homedir();
|
|
148
|
+
const allRules: ParsedRule[] = [];
|
|
149
|
+
|
|
150
|
+
// Step 1: global config — always attempted
|
|
151
|
+
const globalConfigPath = join(getAgentDir(), "ward.json");
|
|
152
|
+
const globalConfig = await readConfigFile(globalConfigPath);
|
|
153
|
+
if (globalConfig !== null) {
|
|
154
|
+
allRules.push(...parseConfigRules(globalConfig, home, globalConfigPath));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Steps 2+3: walk ancestor directories from home to projectRoot (inclusive).
|
|
158
|
+
// ancestorDirs returns [] when projectRoot is outside home, so this is a no-op in that case.
|
|
159
|
+
const dirs = ancestorDirs(home, projectRoot);
|
|
160
|
+
for (const dir of dirs) {
|
|
161
|
+
const configPath = join(dir, ".pi", "ward.json");
|
|
162
|
+
const config = await readConfigFile(configPath);
|
|
163
|
+
if (config !== null) {
|
|
164
|
+
allRules.push(...parseConfigRules(config, dir, configPath));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { rules: allRules };
|
|
169
|
+
}
|
package/src/evaluator.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { matches } from "./matcher.js";
|
|
3
|
+
import type { Effect, Operation, ParsedRule } from "./rules.js";
|
|
4
|
+
import { isDescendantOf } from "./walk.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Evaluate access rules for a given operation on an absolute path.
|
|
8
|
+
*
|
|
9
|
+
* Rules are processed top-to-bottom (first-match-wins).
|
|
10
|
+
* Trust scoping: an `allow` rule only takes effect when the resolved path is
|
|
11
|
+
* at or below the rule's `configDir`.
|
|
12
|
+
*
|
|
13
|
+
* Baseline policy (when no rule matches):
|
|
14
|
+
* - Path at/below projectRoot → allow
|
|
15
|
+
* - Path outside projectRoot → deny
|
|
16
|
+
*
|
|
17
|
+
* @param rules - Ordered list of parsed rules (global first, project last).
|
|
18
|
+
* @param operation - The operation being attempted.
|
|
19
|
+
* @param absolutePath - The resolved absolute path being accessed.
|
|
20
|
+
* @param projectRoot - The session's working directory (resolved absolute path).
|
|
21
|
+
*/
|
|
22
|
+
export function evaluate(rules: ParsedRule[], operation: Operation, absolutePath: string, projectRoot: string): Effect {
|
|
23
|
+
if (!isAbsolute(absolutePath)) {
|
|
24
|
+
throw new Error(`evaluate: absolutePath must be absolute, got "${absolutePath}"`);
|
|
25
|
+
}
|
|
26
|
+
if (!isAbsolute(projectRoot)) {
|
|
27
|
+
throw new Error(`evaluate: projectRoot must be absolute, got "${projectRoot}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const rule of rules) {
|
|
31
|
+
// 1. Operation must match.
|
|
32
|
+
if (!rule.operations.includes(operation)) continue;
|
|
33
|
+
|
|
34
|
+
// 2. Path must match the pattern.
|
|
35
|
+
if (!matches(rule.pattern, rule.configDir, absolutePath)) continue;
|
|
36
|
+
|
|
37
|
+
// 3. Trust scoping: allow rules are only effective within the config's directory.
|
|
38
|
+
if (rule.effect === "allow") {
|
|
39
|
+
if (!isDescendantOf(rule.configDir, absolutePath)) {
|
|
40
|
+
// Path is outside the config's directory — skip this allow rule.
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return rule.effect;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Baseline policy.
|
|
49
|
+
return isDescendantOf(projectRoot, absolutePath) ? "allow" : "deny";
|
|
50
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { evaluate } from "./evaluator.js";
|
|
2
|
+
import { resolvePath } from "./resolve.js";
|
|
3
|
+
import type { Operation, ParsedRule } from "./rules.js";
|
|
4
|
+
import { isSelfProtected } from "./self-protect.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Guard a tool call's path(s) against the configured rules.
|
|
8
|
+
*
|
|
9
|
+
* Returns `{ allowed: true }` when every path passes, or
|
|
10
|
+
* `{ allowed: false; reason: string }` with a formatted `[pi-ward] Blocked ...`
|
|
11
|
+
* message on the first path that fails.
|
|
12
|
+
*/
|
|
13
|
+
export async function guard(
|
|
14
|
+
toolName: string,
|
|
15
|
+
paths: string[],
|
|
16
|
+
operation: Operation,
|
|
17
|
+
rules: ParsedRule[],
|
|
18
|
+
projectRoot: string,
|
|
19
|
+
protectedPaths: string[],
|
|
20
|
+
): Promise<{ allowed: true } | { allowed: false; reason: string }> {
|
|
21
|
+
for (const inputPath of paths) {
|
|
22
|
+
const resolved = await resolvePath(inputPath, projectRoot);
|
|
23
|
+
|
|
24
|
+
if (resolved.denied) {
|
|
25
|
+
return {
|
|
26
|
+
allowed: false,
|
|
27
|
+
reason: `[pi-ward] Blocked ${toolName} (${operation}) on ${inputPath}: ${resolved.denied}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (operation === "write" && isSelfProtected(resolved.path, protectedPaths)) {
|
|
32
|
+
return {
|
|
33
|
+
allowed: false,
|
|
34
|
+
reason: `[pi-ward] Blocked ${toolName} (write) on ${inputPath}: ward config file is write-protected`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const effect = evaluate(rules, operation, resolved.path, projectRoot);
|
|
39
|
+
if (effect === "deny") {
|
|
40
|
+
return {
|
|
41
|
+
allowed: false,
|
|
42
|
+
reason: `[pi-ward] Blocked ${toolName} (${operation}) on ${inputPath}: denied by policy`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { allowed: true };
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,76 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import type { ExtensionFactory, ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { guard } from "./guard.js";
|
|
7
|
+
import type { Operation } from "./rules.js";
|
|
8
|
+
import { getProtectedPaths, resolveProtectedPaths } from "./self-protect.js";
|
|
9
|
+
import { createDeleteTool } from "./tools/delete.js";
|
|
2
10
|
|
|
3
|
-
|
|
4
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Extract the guard operation and path list from a tool call event.
|
|
13
|
+
*
|
|
14
|
+
* Returns `{ operation, paths }` for tools that require access checks, or
|
|
15
|
+
* `null` for tools that should be ignored (e.g. bash).
|
|
16
|
+
*/
|
|
17
|
+
export function extractAccess(
|
|
18
|
+
event: ToolCallEvent,
|
|
19
|
+
projectRoot: string,
|
|
20
|
+
): { operation: Operation; paths: string[] } | null {
|
|
21
|
+
if (isToolCallEventType("read", event)) {
|
|
22
|
+
return { operation: "read", paths: [event.input.path] };
|
|
23
|
+
}
|
|
24
|
+
if (isToolCallEventType("write", event)) {
|
|
25
|
+
return { operation: "write", paths: [event.input.path] };
|
|
26
|
+
}
|
|
27
|
+
if (isToolCallEventType("edit", event)) {
|
|
28
|
+
return { operation: "write", paths: [event.input.path] };
|
|
29
|
+
}
|
|
30
|
+
if (isToolCallEventType("grep", event)) {
|
|
31
|
+
return { operation: "read", paths: [event.input.path ?? projectRoot] };
|
|
32
|
+
}
|
|
33
|
+
if (isToolCallEventType("find", event)) {
|
|
34
|
+
return { operation: "read", paths: [event.input.path ?? projectRoot] };
|
|
35
|
+
}
|
|
36
|
+
if (isToolCallEventType("ls", event)) {
|
|
37
|
+
return { operation: "read", paths: [event.input.path ?? projectRoot] };
|
|
38
|
+
}
|
|
39
|
+
if (isToolCallEventType<"delete", { path: string }>("delete", event)) {
|
|
40
|
+
return { operation: "write", paths: [event.input.path] };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const factory: ExtensionFactory = async (pi) => {
|
|
46
|
+
const projectRoot = await realpath(process.cwd());
|
|
47
|
+
const homeDir = await realpath(homedir());
|
|
48
|
+
const { rules } = await loadConfig(projectRoot, homeDir);
|
|
49
|
+
const protectedPaths = await resolveProtectedPaths(getProtectedPaths(projectRoot, homeDir));
|
|
50
|
+
|
|
51
|
+
pi.registerTool(createDeleteTool(projectRoot));
|
|
52
|
+
|
|
53
|
+
pi.on("tool_call", async (event, _ctx) => {
|
|
54
|
+
try {
|
|
55
|
+
const dispatch = extractAccess(event, projectRoot);
|
|
56
|
+
if (dispatch === null) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { operation, paths } = dispatch;
|
|
61
|
+
const result = await guard(event.toolName, paths, operation, rules, projectRoot, protectedPaths);
|
|
62
|
+
if (!result.allowed) {
|
|
63
|
+
return { block: true, reason: result.reason };
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
return {
|
|
69
|
+
block: true,
|
|
70
|
+
reason: `[pi-ward] Internal error — access denied (fail-closed): ${message}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
});
|
|
5
74
|
};
|
|
6
75
|
|
|
7
76
|
export default factory;
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import type { ParsedPattern, SegmentPattern } from "./pattern.js";
|
|
3
|
+
import { isDescendantOf } from "./walk.js";
|
|
4
|
+
|
|
5
|
+
function matchesSegment(pattern: SegmentPattern, segment: string): boolean {
|
|
6
|
+
const seg = segment.toLowerCase();
|
|
7
|
+
switch (pattern.kind) {
|
|
8
|
+
case "literal":
|
|
9
|
+
return seg === pattern.value.toLowerCase();
|
|
10
|
+
|
|
11
|
+
case "prefix":
|
|
12
|
+
// segment must start with prefix and have at least 1 more char (for the `*`)
|
|
13
|
+
return seg.startsWith(pattern.prefix.toLowerCase()) && seg.length > pattern.prefix.length;
|
|
14
|
+
|
|
15
|
+
case "suffix":
|
|
16
|
+
// segment must end with suffix and have at least 1 more char (for the `*`)
|
|
17
|
+
return seg.endsWith(pattern.suffix.toLowerCase()) && seg.length > pattern.suffix.length;
|
|
18
|
+
|
|
19
|
+
case "both":
|
|
20
|
+
// segment must start with prefix, end with suffix, with at least 1 char in between
|
|
21
|
+
return (
|
|
22
|
+
seg.startsWith(pattern.prefix.toLowerCase()) &&
|
|
23
|
+
seg.endsWith(pattern.suffix.toLowerCase()) &&
|
|
24
|
+
seg.length > pattern.prefix.length + pattern.suffix.length
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
case "wildcard":
|
|
28
|
+
// `*` matches one or more characters — any non-empty segment
|
|
29
|
+
return seg.length > 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function matchesAnchored(pattern: ParsedPattern, configDir: string, absolutePath: string): boolean {
|
|
34
|
+
const rel = relative(configDir, absolutePath);
|
|
35
|
+
if (!isDescendantOf(configDir, absolutePath)) return false;
|
|
36
|
+
|
|
37
|
+
// Segments of the relative path; empty array when absolutePath === configDir
|
|
38
|
+
const relSegments = rel === "" ? [] : rel.split(/[/\\]/).filter((s) => s !== "" && s !== ".");
|
|
39
|
+
|
|
40
|
+
if (pattern.segments.length === 0) return true;
|
|
41
|
+
|
|
42
|
+
const lengthOk = pattern.directory
|
|
43
|
+
? relSegments.length >= pattern.segments.length
|
|
44
|
+
: relSegments.length === pattern.segments.length;
|
|
45
|
+
if (!lengthOk) return false;
|
|
46
|
+
|
|
47
|
+
return pattern.segments.every((seg, i) => matchesSegment(seg, relSegments[i]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Determine whether an absolute path matches a parsed pattern.
|
|
52
|
+
*
|
|
53
|
+
* @param pattern - The parsed pattern to match against.
|
|
54
|
+
* @param configDir - Absolute path to the directory the config governs
|
|
55
|
+
* (used for anchored patterns).
|
|
56
|
+
* @param absolutePath - The absolute path to test.
|
|
57
|
+
*/
|
|
58
|
+
export function matches(pattern: ParsedPattern, configDir: string, absolutePath: string): boolean {
|
|
59
|
+
if (pattern.anchored) return matchesAnchored(pattern, configDir, absolutePath);
|
|
60
|
+
|
|
61
|
+
// Unanchored: the pattern matches if ANY segment in the absolute path matches.
|
|
62
|
+
// For unanchored patterns, the `directory` flag has no additional effect at match time.
|
|
63
|
+
// Both `.secret` and `.secret/` match any path containing a segment that matches the pattern.
|
|
64
|
+
// The trailing `/` in `.secret/` is syntactic sugar indicating intent (this represents a directory),
|
|
65
|
+
// but the matching semantics are identical because segment matching inherently covers
|
|
66
|
+
// both the directory node and paths beneath it.
|
|
67
|
+
const segPattern = pattern.segments[0];
|
|
68
|
+
if (segPattern === undefined) return false;
|
|
69
|
+
|
|
70
|
+
const segments = absolutePath.split(/[/\\]/).filter((s) => s !== "");
|
|
71
|
+
return segments.some((seg) => matchesSegment(segPattern, seg));
|
|
72
|
+
}
|
package/src/pattern.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describes how to match a single path segment.
|
|
3
|
+
* `*` matches one or more characters within the segment (never crosses `/`).
|
|
4
|
+
*/
|
|
5
|
+
export type SegmentPattern =
|
|
6
|
+
| { readonly kind: "literal"; readonly value: string }
|
|
7
|
+
| { readonly kind: "prefix"; readonly prefix: string }
|
|
8
|
+
| { readonly kind: "suffix"; readonly suffix: string }
|
|
9
|
+
| { readonly kind: "both"; readonly prefix: string; readonly suffix: string }
|
|
10
|
+
| { readonly kind: "wildcard" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parsed representation of a ward pattern string.
|
|
14
|
+
*/
|
|
15
|
+
export interface ParsedPattern {
|
|
16
|
+
/** True if the pattern starts with "./" (anchored to the config directory). */
|
|
17
|
+
readonly anchored: boolean;
|
|
18
|
+
/** True if the pattern ends with "/" (directory match). */
|
|
19
|
+
readonly directory: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Segment patterns.
|
|
22
|
+
* - Unanchored: always one element.
|
|
23
|
+
* - Anchored with empty array: matches everything at/below the config dir (`./`).
|
|
24
|
+
* - Anchored non-empty: each element corresponds to a path segment relative to the config dir.
|
|
25
|
+
*/
|
|
26
|
+
readonly segments: ReadonlyArray<SegmentPattern>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseSegmentPattern(seg: string, rawPattern: string): SegmentPattern {
|
|
30
|
+
if (seg === "") {
|
|
31
|
+
throw new Error(`Invalid pattern: empty segment in "${rawPattern}"`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const starCount = (seg.match(/\*/g) ?? []).length;
|
|
35
|
+
|
|
36
|
+
if (starCount === 0) {
|
|
37
|
+
return { kind: "literal", value: seg };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (starCount === 1) {
|
|
41
|
+
if (seg === "*") return { kind: "wildcard" };
|
|
42
|
+
|
|
43
|
+
const starIdx = seg.indexOf("*");
|
|
44
|
+
|
|
45
|
+
if (starIdx === 0) {
|
|
46
|
+
// *.ext — suffix wildcard
|
|
47
|
+
return { kind: "suffix", suffix: seg.slice(1) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (starIdx === seg.length - 1) {
|
|
51
|
+
// foo* — prefix wildcard
|
|
52
|
+
return { kind: "prefix", prefix: seg.slice(0, -1) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// foo*.ext — both
|
|
56
|
+
return {
|
|
57
|
+
kind: "both",
|
|
58
|
+
prefix: seg.slice(0, starIdx),
|
|
59
|
+
suffix: seg.slice(starIdx + 1),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(`Invalid pattern: multiple wildcards in a single segment are not supported: "${rawPattern}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseAnchored(body: string, directory: boolean, raw: string): ParsedPattern {
|
|
67
|
+
if (body === "") {
|
|
68
|
+
return { anchored: true, directory: true, segments: [] };
|
|
69
|
+
}
|
|
70
|
+
const segments = body.split("/").map((part) => parseSegmentPattern(part, raw));
|
|
71
|
+
return { anchored: true, directory, segments };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseUnanchored(body: string, directory: boolean, raw: string): ParsedPattern {
|
|
75
|
+
if (body === "") {
|
|
76
|
+
throw new Error(`Invalid pattern: empty pattern "${raw}"`);
|
|
77
|
+
}
|
|
78
|
+
if (body.includes("/")) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Invalid pattern: unanchored pattern cannot contain "/": "${raw}". Use a "./" prefix for multi-segment patterns.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const segment = parseSegmentPattern(body, raw);
|
|
84
|
+
return { anchored: false, directory, segments: [segment] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a pattern string into a structured ParsedPattern.
|
|
89
|
+
*
|
|
90
|
+
* Syntax rules:
|
|
91
|
+
* - No prefix → unanchored single-segment pattern. Must not contain `/`.
|
|
92
|
+
* - `./` prefix → anchored to the config directory. May contain `/`.
|
|
93
|
+
* - Trailing `/` → directory match (the node itself and everything under it).
|
|
94
|
+
* - `*` → matches one or more characters within a segment (not `/`).
|
|
95
|
+
* - No `**`, braces, extglobs, or regex.
|
|
96
|
+
*
|
|
97
|
+
* @throws on invalid syntax.
|
|
98
|
+
*/
|
|
99
|
+
export function parsePattern(raw: string): ParsedPattern {
|
|
100
|
+
if (raw.includes("**")) {
|
|
101
|
+
throw new Error(`Invalid pattern: "**" is not supported: "${raw}"`);
|
|
102
|
+
}
|
|
103
|
+
if (raw.includes("{")) {
|
|
104
|
+
throw new Error(`Invalid pattern: braces are not supported: "${raw}"`);
|
|
105
|
+
}
|
|
106
|
+
if (raw.includes("}")) {
|
|
107
|
+
throw new Error(`Invalid pattern: braces are not supported: "${raw}"`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const anchored = raw.startsWith("./");
|
|
111
|
+
const directory = raw.endsWith("/");
|
|
112
|
+
|
|
113
|
+
let inner = raw;
|
|
114
|
+
if (anchored) inner = inner.slice(2);
|
|
115
|
+
if (directory) inner = inner.slice(0, -1);
|
|
116
|
+
|
|
117
|
+
return anchored ? parseAnchored(inner, directory, raw) : parseUnanchored(inner, directory, raw);
|
|
118
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ResolveResult {
|
|
5
|
+
/** Resolved absolute real path. Only meaningful when denied is not set. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** If set, the path should be denied with this reason (before rule evaluation). */
|
|
8
|
+
denied?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle the ENOENT case from realpath: distinguish a dangling symlink from a
|
|
13
|
+
* genuinely absent file (new-file write), then resolve the parent directory.
|
|
14
|
+
*/
|
|
15
|
+
async function resolveNewFile(normalized: string): Promise<ResolveResult> {
|
|
16
|
+
try {
|
|
17
|
+
await lstat(normalized);
|
|
18
|
+
// lstat succeeded but realpath failed — dangling/broken symlink.
|
|
19
|
+
return { path: normalized, denied: `Broken symlink at "${normalized}"` };
|
|
20
|
+
} catch (lstatErr) {
|
|
21
|
+
const lstatCode = (lstatErr as { code?: string }).code;
|
|
22
|
+
if (lstatCode !== "ENOENT") {
|
|
23
|
+
// lstat failed for a non-ENOENT reason (e.g. EACCES, ELOOP in path components).
|
|
24
|
+
return { path: normalized, denied: `Cannot resolve path "${normalized}": ${(lstatErr as Error).message}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// File genuinely doesn't exist — attempt to resolve the parent for new-file writes.
|
|
29
|
+
const parent = dirname(normalized);
|
|
30
|
+
const base = basename(normalized);
|
|
31
|
+
try {
|
|
32
|
+
const realParent = await realpath(parent);
|
|
33
|
+
return { path: resolve(realParent, base) };
|
|
34
|
+
} catch (parentErr) {
|
|
35
|
+
return {
|
|
36
|
+
path: normalized,
|
|
37
|
+
denied: `Cannot resolve path "${normalized}": ${(parentErr as Error).message}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an input path to its real absolute path.
|
|
44
|
+
*
|
|
45
|
+
* - Normalizes the path (resolves relative to projectRoot if not absolute).
|
|
46
|
+
* - Resolves symlinks via fs.realpath.
|
|
47
|
+
* - If the target doesn't exist (ENOENT):
|
|
48
|
+
* - Checks lstat to distinguish broken symlinks from genuinely absent files.
|
|
49
|
+
* - Broken/dangling symlink → denied.
|
|
50
|
+
* - File doesn't exist (new file case) → tries parent directory.
|
|
51
|
+
* - Parent resolves → returns parent + basename.
|
|
52
|
+
* - Parent fails → denied.
|
|
53
|
+
* - Any other error → denied (fail-closed).
|
|
54
|
+
*/
|
|
55
|
+
export async function resolvePath(inputPath: string, projectRoot: string): Promise<ResolveResult> {
|
|
56
|
+
const normalized = isAbsolute(inputPath) ? inputPath : resolve(projectRoot, inputPath);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const real = await realpath(normalized);
|
|
60
|
+
return { path: real };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const code = (err as { code?: string }).code;
|
|
63
|
+
if (code === "ENOENT") {
|
|
64
|
+
return resolveNewFile(normalized);
|
|
65
|
+
}
|
|
66
|
+
return { path: normalized, denied: `Cannot resolve path "${normalized}": ${(err as Error).message}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ParsedPattern } from "./pattern.js";
|
|
2
|
+
|
|
3
|
+
export type Operation = "read" | "write";
|
|
4
|
+
export type Effect = "allow" | "deny";
|
|
5
|
+
|
|
6
|
+
export interface Rule {
|
|
7
|
+
pattern: string;
|
|
8
|
+
operations?: Operation[];
|
|
9
|
+
effect: Effect;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParsedRule {
|
|
13
|
+
pattern: ParsedPattern;
|
|
14
|
+
/** Always explicit — expanded from default (both) when omitted on the raw rule. */
|
|
15
|
+
operations: Operation[];
|
|
16
|
+
effect: Effect;
|
|
17
|
+
/** Absolute path to the directory this rule's config governs. */
|
|
18
|
+
configDir: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { ancestorDirs } from "./walk.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns all ward config file paths that are always write-protected.
|
|
9
|
+
*
|
|
10
|
+
* Protected paths (mirrors the loadConfig walk order):
|
|
11
|
+
* - `~/.pi/agent/ward.json` (global config, always protected)
|
|
12
|
+
* - `<dir>/.pi/ward.json` for each directory from homedir to projectRoot (inclusive)
|
|
13
|
+
*
|
|
14
|
+
* If projectRoot is outside homedir, only the global config path is returned.
|
|
15
|
+
*/
|
|
16
|
+
export function getProtectedPaths(projectRoot: string, homeDir?: string): string[] {
|
|
17
|
+
const home = homeDir ?? homedir();
|
|
18
|
+
|
|
19
|
+
// Global config is always protected.
|
|
20
|
+
const paths = [join(getAgentDir(), "ward.json")];
|
|
21
|
+
|
|
22
|
+
// Walk ancestor directories from home to projectRoot (mirrors loadConfig walk).
|
|
23
|
+
// ancestorDirs returns [] when projectRoot is outside home, so this is a no-op in that case.
|
|
24
|
+
const dirs = ancestorDirs(home, projectRoot);
|
|
25
|
+
for (const dir of dirs) {
|
|
26
|
+
paths.push(join(dir, ".pi", "ward.json"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves each protected path via realpath.
|
|
34
|
+
* On any error (ENOENT, EACCES, etc.), keeps the constructed path as a fallback
|
|
35
|
+
* so the path remains protected even when unresolvable.
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveProtectedPaths(paths: string[]): Promise<string[]> {
|
|
38
|
+
const resolved: string[] = [];
|
|
39
|
+
for (const p of paths) {
|
|
40
|
+
try {
|
|
41
|
+
resolved.push(await realpath(p));
|
|
42
|
+
} catch {
|
|
43
|
+
resolved.push(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns true if the given absolute path is a protected ward config file.
|
|
51
|
+
*
|
|
52
|
+
* Protected config files are always write-denied regardless of rules —
|
|
53
|
+
* the agent cannot modify its own access controls.
|
|
54
|
+
*/
|
|
55
|
+
export function isSelfProtected(absolutePath: string, protectedPaths: string[]): boolean {
|
|
56
|
+
return protectedPaths.includes(absolutePath);
|
|
57
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
export function createDeleteTool(projectRoot: string) {
|
|
7
|
+
return defineTool({
|
|
8
|
+
name: "delete",
|
|
9
|
+
label: "Delete",
|
|
10
|
+
description: "Delete a file or empty directory.",
|
|
11
|
+
parameters: Type.Object({
|
|
12
|
+
path: Type.String({ description: "Path to the file or empty directory to delete" }),
|
|
13
|
+
}),
|
|
14
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
15
|
+
const target = resolve(projectRoot, params.path);
|
|
16
|
+
try {
|
|
17
|
+
await rm(target);
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text" as const, text: `Deleted: ${target}` }],
|
|
20
|
+
details: undefined,
|
|
21
|
+
};
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
26
|
+
details: undefined,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
2
|
+
|
|
3
|
+
function isParentTraversal(rel: string): boolean {
|
|
4
|
+
return rel === ".." || rel.startsWith("../") || rel.startsWith("..\\");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true if `target` is at or below `base` in the directory tree.
|
|
9
|
+
*/
|
|
10
|
+
export function isDescendantOf(base: string, target: string): boolean {
|
|
11
|
+
const rel = relative(base, target);
|
|
12
|
+
return !isAbsolute(rel) && !isParentTraversal(rel);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute ancestor directories from `home` toward `target`.
|
|
17
|
+
* Returns an array of directory paths in walk order (home first, target last).
|
|
18
|
+
* If target is not within home, returns an empty array.
|
|
19
|
+
*/
|
|
20
|
+
export function ancestorDirs(home: string, target: string): string[] {
|
|
21
|
+
if (!isDescendantOf(home, target)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rel = relative(home, target);
|
|
26
|
+
// rel is "" when home === target, otherwise segments separated by OS sep
|
|
27
|
+
const segments = rel === "" ? [] : rel.split(/[/\\]/).filter((s) => s !== "");
|
|
28
|
+
|
|
29
|
+
const dirs: string[] = [home];
|
|
30
|
+
let current = home;
|
|
31
|
+
for (const seg of segments) {
|
|
32
|
+
current = join(current, seg);
|
|
33
|
+
dirs.push(current);
|
|
34
|
+
}
|
|
35
|
+
return dirs;
|
|
36
|
+
}
|