@yagni-app/code 1.0.6 → 1.0.7
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 +84 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +13 -0
- package/dist/extension/index.js +230 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +97 -2
- package/dist/extension/permission/gate.js +375 -26
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/package.json +19 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated bash command → file-operation table.
|
|
3
|
+
*
|
|
4
|
+
* The bridge that makes Read/Edit deny rules bite on bash commands: for a
|
|
5
|
+
* curated set of commands, extract the file paths they touch, classify each
|
|
6
|
+
* touch as read or write, and run those paths through the same path-rule
|
|
7
|
+
* engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
|
|
8
|
+
* caught by a `Read(.env)`/`Edit(.env)` deny.
|
|
9
|
+
*
|
|
10
|
+
* Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
|
|
11
|
+
* COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
|
|
12
|
+
* posture as Claude Code):
|
|
13
|
+
* - unknown flags on commands where flags can change path meaning
|
|
14
|
+
* (mv/cp --target-directory) → the command is NOT auto-analyzed; it
|
|
15
|
+
* degrades to the caller's "ask" path, never silently allowed.
|
|
16
|
+
* - glob arguments in write position → not analyzed (write globs bypass
|
|
17
|
+
* checks in Claude Code; we degrade to ask).
|
|
18
|
+
* - anything unextractable → no opinion; caller decides.
|
|
19
|
+
*/
|
|
20
|
+
export type FileOperation = "read" | "write";
|
|
21
|
+
/** A command's opinion: extracted (op, paths) or needs-ask. */
|
|
22
|
+
export type BashFileArgsResult = {
|
|
23
|
+
kind: "analyzed";
|
|
24
|
+
operation: FileOperation;
|
|
25
|
+
paths: string[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "unknown";
|
|
28
|
+
};
|
|
29
|
+
/** Commands whose file paths we can extract confidently. */
|
|
30
|
+
export declare const COMMAND_OPERATION: Record<string, FileOperation>;
|
|
31
|
+
/**
|
|
32
|
+
* Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
|
|
33
|
+
* opinion — the caller degrades to ask for deny-evaluation purposes (never
|
|
34
|
+
* to silent allow).
|
|
35
|
+
*/
|
|
36
|
+
export declare function analyzeSubcommand(subcommand: string): BashFileArgsResult;
|
|
37
|
+
/** Split a full command into subcommand strings (reuses shellRules' splitter). */
|
|
38
|
+
export { splitSubcommands } from "./shellRules.js";
|
|
39
|
+
//# sourceMappingURL=bashFileArgs.d.ts.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated bash command → file-operation table.
|
|
3
|
+
*
|
|
4
|
+
* The bridge that makes Read/Edit deny rules bite on bash commands: for a
|
|
5
|
+
* curated set of commands, extract the file paths they touch, classify each
|
|
6
|
+
* touch as read or write, and run those paths through the same path-rule
|
|
7
|
+
* engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
|
|
8
|
+
* caught by a `Read(.env)`/`Edit(.env)` deny.
|
|
9
|
+
*
|
|
10
|
+
* Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
|
|
11
|
+
* COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
|
|
12
|
+
* posture as Claude Code):
|
|
13
|
+
* - unknown flags on commands where flags can change path meaning
|
|
14
|
+
* (mv/cp --target-directory) → the command is NOT auto-analyzed; it
|
|
15
|
+
* degrades to the caller's "ask" path, never silently allowed.
|
|
16
|
+
* - glob arguments in write position → not analyzed (write globs bypass
|
|
17
|
+
* checks in Claude Code; we degrade to ask).
|
|
18
|
+
* - anything unextractable → no opinion; caller decides.
|
|
19
|
+
*/
|
|
20
|
+
import { shellParse } from "../permission/execPolicy.js";
|
|
21
|
+
/** Commands whose file paths we can extract confidently. */
|
|
22
|
+
export const COMMAND_OPERATION = {
|
|
23
|
+
// read-only
|
|
24
|
+
cat: "read", head: "read", tail: "read", sort: "read", uniq: "read",
|
|
25
|
+
wc: "read", cut: "read", paste: "read", column: "read", file: "read",
|
|
26
|
+
stat: "read", diff: "read", awk: "read", strings: "read", hexdump: "read",
|
|
27
|
+
od: "read", base64: "read", nl: "read", grep: "read", rg: "read",
|
|
28
|
+
sha256sum: "read", sha1sum: "read", md5sum: "read", jq: "read",
|
|
29
|
+
ls: "read", find: "read", git: "read",
|
|
30
|
+
// mutating on their targets
|
|
31
|
+
rm: "write", rmdir: "write", mv: "write", cp: "write", touch: "write",
|
|
32
|
+
mkdir: "write", sed: "write", tee: "write",
|
|
33
|
+
};
|
|
34
|
+
// --- token helpers (over shellParse output) ---
|
|
35
|
+
function argTokens(command) {
|
|
36
|
+
const parsed = shellParse(command);
|
|
37
|
+
const tokens = [];
|
|
38
|
+
const redirects = [];
|
|
39
|
+
for (const t of parsed) {
|
|
40
|
+
if (typeof t === "string") {
|
|
41
|
+
tokens.push(t);
|
|
42
|
+
}
|
|
43
|
+
else if ("op" in t) {
|
|
44
|
+
if (t.op === "redirect" && t.direction === "out" && typeof t.target === "string") {
|
|
45
|
+
redirects.push(t.target);
|
|
46
|
+
}
|
|
47
|
+
else if (t.op !== "background") {
|
|
48
|
+
// substitution / in-redirect / background: can't analyze confidently
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { tokens, redirects };
|
|
54
|
+
}
|
|
55
|
+
/** Drop flag tokens, honoring `--` end-of-options (Claude's filterOutFlags). */
|
|
56
|
+
function filterOutFlags(args) {
|
|
57
|
+
const out = [];
|
|
58
|
+
let afterDoubleDash = false;
|
|
59
|
+
for (const a of args) {
|
|
60
|
+
if (!afterDoubleDash && a === "--") {
|
|
61
|
+
afterDoubleDash = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!afterDoubleDash && a.startsWith("-"))
|
|
65
|
+
continue;
|
|
66
|
+
out.push(a);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Pattern-first commands (grep/rg/awk/sed-style): first positional is the pattern. */
|
|
71
|
+
function patternCommandPaths(args, flagsWithValues, defaults) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let skipNext = false;
|
|
74
|
+
let patternFound = false;
|
|
75
|
+
let afterDoubleDash = false;
|
|
76
|
+
for (const a of args) {
|
|
77
|
+
if (skipNext) {
|
|
78
|
+
skipNext = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!afterDoubleDash && a === "--") {
|
|
82
|
+
afterDoubleDash = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!afterDoubleDash && a.startsWith("-")) {
|
|
86
|
+
const flag = a.split("=")[0];
|
|
87
|
+
if (flagsWithValues.has(flag) && !a.includes("="))
|
|
88
|
+
skipNext = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!patternFound) {
|
|
92
|
+
patternFound = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push(a);
|
|
96
|
+
}
|
|
97
|
+
return out.length > 0 ? out : defaults;
|
|
98
|
+
}
|
|
99
|
+
const GREP_FLAGS = new Set(["-e", "--regexp", "-f", "--file", "--exclude", "--include", "--exclude-dir", "--include-dir", "-m", "--max-count", "-A", "--after-context", "-B", "--before-context", "-C", "--context"]);
|
|
100
|
+
const RG_FLAGS = new Set(["-e", "--regexp", "-f", "--file", "-t", "--type", "-T", "--type-not", "-g", "--glob", "-m", "--max-count", "--max-depth", "-r", "--replace", "-A", "--after-context", "-B", "--before-context", "-C", "--context"]);
|
|
101
|
+
// mv/cp validators: ANY flag → unknown (Claude blocks all flags for these —
|
|
102
|
+
// --target-directory=PATH changes path meaning).
|
|
103
|
+
function hasFlag(args) {
|
|
104
|
+
let afterDoubleDash = false;
|
|
105
|
+
for (const a of args) {
|
|
106
|
+
if (!afterDoubleDash && a === "--") {
|
|
107
|
+
afterDoubleDash = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!afterDoubleDash && a.startsWith("-") && a !== "-")
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const GLOBISH = /[*?[]/;
|
|
116
|
+
/**
|
|
117
|
+
* Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
|
|
118
|
+
* opinion — the caller degrades to ask for deny-evaluation purposes (never
|
|
119
|
+
* to silent allow).
|
|
120
|
+
*/
|
|
121
|
+
export function analyzeSubcommand(subcommand) {
|
|
122
|
+
const parsed = argTokens(subcommand);
|
|
123
|
+
if (!parsed || parsed.tokens.length === 0)
|
|
124
|
+
return { kind: "unknown" };
|
|
125
|
+
// Redirect targets are ALWAYS write access, regardless of the command
|
|
126
|
+
// being curated (Claude Code validates redirect targets independently of
|
|
127
|
+
// the command table). `echo x > .env` must not escape the .env deny.
|
|
128
|
+
if (parsed.redirects.length > 0) {
|
|
129
|
+
const [cmd, ...args] = parsed.tokens;
|
|
130
|
+
const operation = COMMAND_OPERATION[cmd];
|
|
131
|
+
const paths = operation
|
|
132
|
+
? extractPaths(cmd, args)
|
|
133
|
+
: [];
|
|
134
|
+
if (paths === null)
|
|
135
|
+
return { kind: "unknown" };
|
|
136
|
+
const base = paths.filter((p) => p.length > 0);
|
|
137
|
+
if (operation === "write" && base.some((p) => GLOBISH.test(p)))
|
|
138
|
+
return { kind: "unknown" };
|
|
139
|
+
return { kind: "analyzed", operation: "write", paths: [...base, ...parsed.redirects] };
|
|
140
|
+
}
|
|
141
|
+
const [cmd, ...args] = parsed.tokens;
|
|
142
|
+
const operation = COMMAND_OPERATION[cmd];
|
|
143
|
+
if (!operation)
|
|
144
|
+
return { kind: "unknown" };
|
|
145
|
+
const paths = extractPaths(cmd, args);
|
|
146
|
+
if (paths === null)
|
|
147
|
+
return { kind: "unknown" };
|
|
148
|
+
if (operation === "write" && paths.some((p) => GLOBISH.test(p))) {
|
|
149
|
+
return { kind: "unknown" };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "analyzed", operation, paths: paths.filter((p) => p.length > 0) };
|
|
152
|
+
}
|
|
153
|
+
function extractPaths(cmd, args) {
|
|
154
|
+
switch (cmd) {
|
|
155
|
+
case "grep":
|
|
156
|
+
return patternCommandPaths(args, GREP_FLAGS, []);
|
|
157
|
+
case "rg":
|
|
158
|
+
return patternCommandPaths(args, RG_FLAGS, ["."]);
|
|
159
|
+
case "ls": {
|
|
160
|
+
const out = filterOutFlags(args);
|
|
161
|
+
return out.length > 0 ? out : ["."];
|
|
162
|
+
}
|
|
163
|
+
case "find": {
|
|
164
|
+
// collect positionals until the first real flag
|
|
165
|
+
const out = [];
|
|
166
|
+
let hitFlag = false;
|
|
167
|
+
for (const a of args) {
|
|
168
|
+
if (a.startsWith("-")) {
|
|
169
|
+
hitFlag = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!hitFlag)
|
|
173
|
+
out.push(a);
|
|
174
|
+
}
|
|
175
|
+
return out.length > 0 ? out : ["."];
|
|
176
|
+
}
|
|
177
|
+
case "git":
|
|
178
|
+
// Only `git diff --no-index` reaches arbitrary files outside the repo;
|
|
179
|
+
// other git subcommands are covered by the repo's own boundaries.
|
|
180
|
+
if (args[0] === "diff" && args.includes("--no-index")) {
|
|
181
|
+
return filterOutFlags(args.slice(1)).slice(0, 2);
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
case "sed": {
|
|
185
|
+
// -f FILE reads a script file; positionals after the script are files.
|
|
186
|
+
const out = [];
|
|
187
|
+
let skipNext = false;
|
|
188
|
+
let scriptFound = false;
|
|
189
|
+
let afterDoubleDash = false;
|
|
190
|
+
for (const a of args) {
|
|
191
|
+
if (skipNext) {
|
|
192
|
+
skipNext = false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!afterDoubleDash && a === "--") {
|
|
196
|
+
afterDoubleDash = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!afterDoubleDash && a.startsWith("-")) {
|
|
200
|
+
if (a === "-f" || a === "--file") {
|
|
201
|
+
const f = args[args.indexOf(a) + 1];
|
|
202
|
+
if (f)
|
|
203
|
+
out.push(f);
|
|
204
|
+
skipNext = true;
|
|
205
|
+
scriptFound = true;
|
|
206
|
+
}
|
|
207
|
+
else if (a === "-e" || a === "--expression") {
|
|
208
|
+
skipNext = true;
|
|
209
|
+
scriptFound = true;
|
|
210
|
+
}
|
|
211
|
+
else if (a.includes("e") || a.includes("f")) {
|
|
212
|
+
scriptFound = true;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!scriptFound) {
|
|
217
|
+
scriptFound = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
out.push(a);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
case "mv":
|
|
225
|
+
case "cp":
|
|
226
|
+
// ANY flag → unknown (--target-directory=PATH changes path meaning).
|
|
227
|
+
if (hasFlag(args))
|
|
228
|
+
return null;
|
|
229
|
+
return filterOutFlags(args);
|
|
230
|
+
default:
|
|
231
|
+
return filterOutFlags(args);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Split a full command into subcommand strings (reuses shellRules' splitter). */
|
|
235
|
+
export { splitSubcommands } from "./shellRules.js";
|
|
236
|
+
//# sourceMappingURL=bashFileArgs.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-rule evaluation engine.
|
|
3
|
+
*
|
|
4
|
+
* The single entry the gate calls BEFORE hooks/exec-policy/grants/Guardian:
|
|
5
|
+
*
|
|
6
|
+
* evaluateRules(rules, { toolName, params, cwd, isProjectTrusted, ... })
|
|
7
|
+
* → { verdict: "deny" | "ask" | "allow", rule } | null (no opinion)
|
|
8
|
+
*
|
|
9
|
+
* Evaluation order is Claude Code's: for each behavior in deny → ask → allow,
|
|
10
|
+
* find the first rule that matches the tool call. Deny/ask are FINAL from the
|
|
11
|
+
* gate's perspective (nothing downstream may override); allow short-circuits
|
|
12
|
+
* the Guardian but CANNOT lift the exec-policy forbidden band or the
|
|
13
|
+
* alwaysConfirmTools contract (those floors live in the gate wiring, which
|
|
14
|
+
* re-checks them after an allow verdict).
|
|
15
|
+
*
|
|
16
|
+
* Matching by tool:
|
|
17
|
+
* - bash: Bash(command-pattern): prefix, :* suffix, or * wildcards; compound split
|
|
18
|
+
* - read/edit/write: Read(path)/Edit(path) — gitignore path patterns
|
|
19
|
+
* (read-class rules also consult grep/find/ls paths)
|
|
20
|
+
* - web_fetch: WebFetch(domain:pattern) — hostname globs
|
|
21
|
+
* - others: bare tool-name rules (and mcp__server / mcp__server__tool)
|
|
22
|
+
*
|
|
23
|
+
* Edit-class deny also applies to read access of the same path (Claude Code:
|
|
24
|
+
* a Read deny blocks Edit/Write on the path; an Edit deny blocks reads too —
|
|
25
|
+
* "edit access implies read access" cuts both ways for denies).
|
|
26
|
+
*/
|
|
27
|
+
import type { PermissionRule, RuleSource } from "./loadConfig.js";
|
|
28
|
+
export type RuleVerdict = "deny" | "ask" | "allow";
|
|
29
|
+
export interface RuleEvaluationContext {
|
|
30
|
+
toolName: string;
|
|
31
|
+
params: Record<string, unknown>;
|
|
32
|
+
cwd: string;
|
|
33
|
+
isProjectTrusted: boolean;
|
|
34
|
+
/** ~/.yagni-code (user `/`-anchor base). */
|
|
35
|
+
userStateHome: string;
|
|
36
|
+
/** Project root for project-source `/`-anchored patterns; null outside a repo. */
|
|
37
|
+
projectRoot: string | null;
|
|
38
|
+
/** Overrides ~ expansion (tests); defaults to os.homedir(). */
|
|
39
|
+
homeDir?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RuleEvaluation {
|
|
42
|
+
verdict: RuleVerdict;
|
|
43
|
+
rule: PermissionRule;
|
|
44
|
+
/** Which matcher produced the hit (verdict logs / deny messages). */
|
|
45
|
+
matchedVia: "tool_name" | "bash_pattern" | "path_pattern" | "domain_pattern" | "file_args";
|
|
46
|
+
}
|
|
47
|
+
export declare function evaluateRules(rules: readonly PermissionRule[], ctx: RuleEvaluationContext): RuleEvaluation | null;
|
|
48
|
+
/** Parse a raw rule string into a sourced PermissionRule (loader/tests). */
|
|
49
|
+
export declare function ruleFromRaw(raw: string, behavior: PermissionRule["behavior"], source: RuleSource): PermissionRule;
|
|
50
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-rule evaluation engine.
|
|
3
|
+
*
|
|
4
|
+
* The single entry the gate calls BEFORE hooks/exec-policy/grants/Guardian:
|
|
5
|
+
*
|
|
6
|
+
* evaluateRules(rules, { toolName, params, cwd, isProjectTrusted, ... })
|
|
7
|
+
* → { verdict: "deny" | "ask" | "allow", rule } | null (no opinion)
|
|
8
|
+
*
|
|
9
|
+
* Evaluation order is Claude Code's: for each behavior in deny → ask → allow,
|
|
10
|
+
* find the first rule that matches the tool call. Deny/ask are FINAL from the
|
|
11
|
+
* gate's perspective (nothing downstream may override); allow short-circuits
|
|
12
|
+
* the Guardian but CANNOT lift the exec-policy forbidden band or the
|
|
13
|
+
* alwaysConfirmTools contract (those floors live in the gate wiring, which
|
|
14
|
+
* re-checks them after an allow verdict).
|
|
15
|
+
*
|
|
16
|
+
* Matching by tool:
|
|
17
|
+
* - bash: Bash(command-pattern): prefix, :* suffix, or * wildcards; compound split
|
|
18
|
+
* - read/edit/write: Read(path)/Edit(path) — gitignore path patterns
|
|
19
|
+
* (read-class rules also consult grep/find/ls paths)
|
|
20
|
+
* - web_fetch: WebFetch(domain:pattern) — hostname globs
|
|
21
|
+
* - others: bare tool-name rules (and mcp__server / mcp__server__tool)
|
|
22
|
+
*
|
|
23
|
+
* Edit-class deny also applies to read access of the same path (Claude Code:
|
|
24
|
+
* a Read deny blocks Edit/Write on the path; an Edit deny blocks reads too —
|
|
25
|
+
* "edit access implies read access" cuts both ways for denies).
|
|
26
|
+
*/
|
|
27
|
+
import { mcpInfoFromString } from "../mcp/names.js";
|
|
28
|
+
import { analyzeSubcommand, splitSubcommands } from "./bashFileArgs.js";
|
|
29
|
+
import { effectiveRules } from "./loadConfig.js";
|
|
30
|
+
import { pathClassForTool, pathMatches, resolvePatternRoot } from "./pathRules.js";
|
|
31
|
+
import { parseRuleString } from "./parser.js";
|
|
32
|
+
import { shellRuleMatches } from "./shellRules.js";
|
|
33
|
+
// --- tool-name matching (Claude Code's toolMatchesRule, incl. MCP) ---
|
|
34
|
+
function toolNameMatches(ruleTool, callTool) {
|
|
35
|
+
if (ruleTool === callTool)
|
|
36
|
+
return true;
|
|
37
|
+
if (ruleTool === "*")
|
|
38
|
+
return true;
|
|
39
|
+
// MCP: `mcp__server` matches every tool of that server; `mcp__server__tool`
|
|
40
|
+
// matches exactly; `mcp__server__*` is the glob form.
|
|
41
|
+
const ruleInfo = mcpInfoFromString(ruleTool);
|
|
42
|
+
const callInfo = mcpInfoFromString(callTool);
|
|
43
|
+
if (ruleInfo && callInfo) {
|
|
44
|
+
if (ruleInfo.serverName !== callInfo.serverName)
|
|
45
|
+
return false;
|
|
46
|
+
if (ruleInfo.toolName === undefined || ruleInfo.toolName === "*")
|
|
47
|
+
return true;
|
|
48
|
+
return ruleInfo.toolName === callInfo.toolName;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// --- WebFetch domain matching ---
|
|
53
|
+
function domainMatches(pattern, hostname) {
|
|
54
|
+
const p = pattern.toLowerCase().trim();
|
|
55
|
+
const h = hostname.toLowerCase();
|
|
56
|
+
if (p === "*" || p === "domain:*")
|
|
57
|
+
return true;
|
|
58
|
+
if (!p.includes("*"))
|
|
59
|
+
return p === h;
|
|
60
|
+
// `*.example.com` matches any depth of subdomain (docs semantics); a bare
|
|
61
|
+
// `example.com` does NOT match sub.example.com (exact only).
|
|
62
|
+
if (p.startsWith("*.")) {
|
|
63
|
+
const base = p.slice(2);
|
|
64
|
+
// `*.example.com` matches subdomains ONLY (not the apex) — the strict
|
|
65
|
+
// reading of Claude Code's docs; an apex allow belongs in its own rule.
|
|
66
|
+
return h.endsWith("." + base);
|
|
67
|
+
}
|
|
68
|
+
// Other wildcard placements: simple glob (segment-safe).
|
|
69
|
+
const re = new RegExp("^" + p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^.]*") + "$");
|
|
70
|
+
return re.test(h);
|
|
71
|
+
}
|
|
72
|
+
function hostnameOf(url) {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).hostname;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// --- main evaluation ---
|
|
81
|
+
export function evaluateRules(rules, ctx) {
|
|
82
|
+
const active = effectiveRules(rules, ctx.isProjectTrusted);
|
|
83
|
+
for (const behavior of ["deny", "ask", "allow"]) {
|
|
84
|
+
const hit = firstMatch(active, behavior, ctx);
|
|
85
|
+
if (hit)
|
|
86
|
+
return { verdict: behavior, ...hit };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function firstMatch(rules, behavior, ctx) {
|
|
91
|
+
// Path-class rules evaluate as ONE ordered compile (gitignore semantics:
|
|
92
|
+
// later lines win, !negation works across rules and files). Non-path rules
|
|
93
|
+
// stay first-match (Claude Code's per-rule loop).
|
|
94
|
+
const pathRules = rules.filter((r) => r.behavior === behavior && r.ruleContent !== undefined && pathClassForTool(r.toolName) !== null);
|
|
95
|
+
if (pathRules.length > 0 && pathClassForTool(ctx.toolName) !== null) {
|
|
96
|
+
const hit = pathListMatch(pathRules, behavior, ctx);
|
|
97
|
+
if (hit)
|
|
98
|
+
return hit;
|
|
99
|
+
}
|
|
100
|
+
for (const rule of rules) {
|
|
101
|
+
if (rule.behavior !== behavior)
|
|
102
|
+
continue;
|
|
103
|
+
if (pathRules.includes(rule) && pathClassForTool(ctx.toolName) !== null)
|
|
104
|
+
continue; // handled above
|
|
105
|
+
const via = ruleMatchesCall(rule, ctx);
|
|
106
|
+
if (via)
|
|
107
|
+
return { rule, matchedVia: via };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/** Ordered whole-list evaluation for path-class rules of one behavior. */
|
|
112
|
+
function pathListMatch(pathRules, behavior, ctx) {
|
|
113
|
+
const callClass = pathClassForTool(ctx.toolName);
|
|
114
|
+
if (!callClass)
|
|
115
|
+
return null;
|
|
116
|
+
const path = pathParam(ctx.params);
|
|
117
|
+
const resolveOpts = {
|
|
118
|
+
userStateHome: ctx.userStateHome,
|
|
119
|
+
projectRoot: ctx.projectRoot,
|
|
120
|
+
...(ctx.homeDir ? { homeDir: ctx.homeDir } : {}),
|
|
121
|
+
};
|
|
122
|
+
// Class applicability (Claude Code parity):
|
|
123
|
+
// - Edit-class rules apply to edit AND read tools (edit implies read).
|
|
124
|
+
// - Read-class DENY/ASK rules also apply to edit tools ("Read deny rules
|
|
125
|
+
// also block Edit/Write on the same path" — their docs); only Read-class
|
|
126
|
+
// ALLOW is read-tool-only (an allow must never widen).
|
|
127
|
+
const applicable = pathRules.filter((r) => {
|
|
128
|
+
const rc = pathClassForTool(r.toolName);
|
|
129
|
+
if (rc === "edit")
|
|
130
|
+
return true;
|
|
131
|
+
return callClass === "read" || behavior !== "allow";
|
|
132
|
+
});
|
|
133
|
+
if (path && applicable.length > 0) {
|
|
134
|
+
const patterns = applicable.map((r) => resolvePatternRoot(r.ruleContent ?? "", r.source, resolveOpts));
|
|
135
|
+
if (anchoredPathMatches(patterns, path, behavior, ctx)) {
|
|
136
|
+
// Report the LAST rule whose pattern decided the outcome (gitignore's
|
|
137
|
+
// decisive line) — the list is user-before-project, in file order.
|
|
138
|
+
return { rule: applicable[applicable.length - 1], matchedVia: "path_pattern" };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// The bash bridge runs per-rule (bridge matching is per-command-segment,
|
|
142
|
+
// not an ordered list op) — but only for deny/ask.
|
|
143
|
+
if (ctx.toolName === "bash" && behavior !== "allow") {
|
|
144
|
+
for (const rule of applicable) {
|
|
145
|
+
if (bashBridgeMatches(rule, ctx))
|
|
146
|
+
return { rule, matchedVia: "file_args" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/** Ordered compile + the allow-vs-deny anchoring asymmetry. */
|
|
152
|
+
function anchoredPathMatches(patterns, targetPath, behavior, ctx) {
|
|
153
|
+
if (!pathMatches(patterns, targetPath, { cwd: ctx.cwd }))
|
|
154
|
+
return false;
|
|
155
|
+
if (behavior !== "allow")
|
|
156
|
+
return true;
|
|
157
|
+
// Rootless single-segment allow patterns are narrowed (Claude Code's
|
|
158
|
+
// asymmetry): only anchored or multi-segment allow patterns may match.
|
|
159
|
+
const last = patterns[patterns.length - 1];
|
|
160
|
+
const content = last.relativePattern;
|
|
161
|
+
if (content.startsWith("/") || content.includes("/"))
|
|
162
|
+
return true;
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
function ruleMatchesCall(rule, ctx) {
|
|
166
|
+
// Tool-name-level match first (bare rules + MCP shapes).
|
|
167
|
+
if (rule.ruleContent === undefined) {
|
|
168
|
+
return toolNameMatches(rule.toolName, ctx.toolName) ? "tool_name" : null;
|
|
169
|
+
}
|
|
170
|
+
if (rule.toolName === "bash") {
|
|
171
|
+
const command = typeof ctx.params.command === "string" ? ctx.params.command : "";
|
|
172
|
+
if (!command)
|
|
173
|
+
return null;
|
|
174
|
+
return shellRuleMatches(rule.ruleContent, command, rule.behavior) ? "bash_pattern" : null;
|
|
175
|
+
}
|
|
176
|
+
if (rule.toolName === "web_fetch") {
|
|
177
|
+
const url = typeof ctx.params.url === "string" ? ctx.params.url : "";
|
|
178
|
+
const host = hostnameOf(url);
|
|
179
|
+
if (!host)
|
|
180
|
+
return null;
|
|
181
|
+
const m = rule.ruleContent.match(/^domain:(.*)$/);
|
|
182
|
+
if (!m)
|
|
183
|
+
return null;
|
|
184
|
+
return domainMatches(m[1], host) ? "domain_pattern" : null;
|
|
185
|
+
}
|
|
186
|
+
// Path-class rules were already handled by pathListMatch in firstMatch;
|
|
187
|
+
// ruleMatchesCall only sees them for the bash bridge (non-path call tool)
|
|
188
|
+
// or not at all. Keep the bridge branch here for direct callers.
|
|
189
|
+
const ruleClass = pathClassForTool(rule.toolName);
|
|
190
|
+
if (ruleClass) {
|
|
191
|
+
if (ctx.toolName === "bash" && rule.behavior !== "allow") {
|
|
192
|
+
if (bashBridgeMatches(rule, ctx))
|
|
193
|
+
return "file_args";
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
// Non-path tools with content (e.g. Agent(name)) — exact content match.
|
|
198
|
+
if (rule.ruleContent === ctx.toolName)
|
|
199
|
+
return null;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function pathParam(params) {
|
|
203
|
+
const p = params.path ?? params.file_path;
|
|
204
|
+
return typeof p === "string" && p.length > 0 ? p : null;
|
|
205
|
+
}
|
|
206
|
+
/** Bash bridge: does this path rule bite on the command's file arguments? */
|
|
207
|
+
function bashBridgeMatches(rule, ctx) {
|
|
208
|
+
const command = typeof ctx.params.command === "string" ? ctx.params.command : "";
|
|
209
|
+
if (!command)
|
|
210
|
+
return false;
|
|
211
|
+
if (pathClassForTool(rule.toolName) === null)
|
|
212
|
+
return false;
|
|
213
|
+
for (const segment of splitSubcommands(command)) {
|
|
214
|
+
const analysis = analyzeSubcommand(segment);
|
|
215
|
+
if (analysis.kind !== "analyzed")
|
|
216
|
+
continue;
|
|
217
|
+
// Claude Code parity: a deny/ask rule for the path bites on ANY file
|
|
218
|
+
// operation the command performs on it — read-class rules are NOT
|
|
219
|
+
// limited to read ops (their `checkPathConstraints` consults the same
|
|
220
|
+
// patterns for write ops). Fail-safe: a deny is a deny.
|
|
221
|
+
for (const p of analysis.paths) {
|
|
222
|
+
if (pathMatches([resolvePatternRoot(rule.ruleContent ?? "", rule.source, {
|
|
223
|
+
userStateHome: ctx.userStateHome,
|
|
224
|
+
projectRoot: ctx.projectRoot,
|
|
225
|
+
...(ctx.homeDir ? { homeDir: ctx.homeDir } : {}),
|
|
226
|
+
})], p, { cwd: ctx.cwd })) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
/** Parse a raw rule string into a sourced PermissionRule (loader/tests). */
|
|
234
|
+
export function ruleFromRaw(raw, behavior, source) {
|
|
235
|
+
const { toolName, ruleContent } = parseRuleString(raw);
|
|
236
|
+
return { behavior, source, toolName, ruleContent, raw };
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-rule settings loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads `permissions: { allow, deny, ask }` from the two YAGNI Code settings
|
|
5
|
+
* files, mirroring the hooks loader (hooks.ts) shape exactly:
|
|
6
|
+
* - user: ~/.yagni-code/config.json — always active
|
|
7
|
+
* - project: .yagni-code/config.json — deny/ask always active;
|
|
8
|
+
* allow gated on workspace trust (Claude Code semantics:
|
|
9
|
+
* "deny and ask rules apply right away; allow rules from a
|
|
10
|
+
* project file wait for trust")
|
|
11
|
+
*
|
|
12
|
+
* Lists UNION across sources (Claude Code: lists merge, never replace).
|
|
13
|
+
* User-source rules are ordered before project-source rules so path-pattern
|
|
14
|
+
* negation (`!exception`) behaves like gitignore lines across files.
|
|
15
|
+
*
|
|
16
|
+
* Fail-soft: a malformed config file is skipped whole with a warning (same
|
|
17
|
+
* posture as hooks.ts). Malformed individual rule strings parse as bare
|
|
18
|
+
* tool names (parser degrades, never throws) and are flagged.
|
|
19
|
+
*/
|
|
20
|
+
export type RuleSource = "user" | "project";
|
|
21
|
+
export type RuleBehavior = "allow" | "deny" | "ask";
|
|
22
|
+
/** A parsed, sourced permission rule. */
|
|
23
|
+
export interface PermissionRule {
|
|
24
|
+
behavior: RuleBehavior;
|
|
25
|
+
source: RuleSource;
|
|
26
|
+
toolName: string;
|
|
27
|
+
ruleContent?: string;
|
|
28
|
+
/** The original rule string, for diagnostics and verdict logs. */
|
|
29
|
+
raw: string;
|
|
30
|
+
}
|
|
31
|
+
export interface LoadRulesDiagnostics {
|
|
32
|
+
/** Non-fatal problems: bad files, unparsed rules, unknown keys. */
|
|
33
|
+
warnings: string[];
|
|
34
|
+
/** Rule tool names that no rule consumer consults (Claude-only tools). */
|
|
35
|
+
neverConsultedTools: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface LoadedRules {
|
|
38
|
+
rules: PermissionRule[];
|
|
39
|
+
diagnostics: LoadRulesDiagnostics;
|
|
40
|
+
}
|
|
41
|
+
/** The tools a rule can actually be consulted for at the gate. */
|
|
42
|
+
export declare const KNOWN_RULE_TOOLS: Set<string>;
|
|
43
|
+
/** Load + union rules from both files. Pure I/O; no throw. */
|
|
44
|
+
export declare function loadPermissionRules(opts?: {
|
|
45
|
+
cwd?: string;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
userHome?: string;
|
|
48
|
+
/** Override the user state home (tests). */
|
|
49
|
+
stateHomeOverride?: string | null;
|
|
50
|
+
}): LoadedRules;
|
|
51
|
+
/** Filter rules by behavior + trust (project allow needs trust; deny/ask always). */
|
|
52
|
+
export declare function effectiveRules(rules: readonly PermissionRule[], isProjectTrusted: boolean): PermissionRule[];
|
|
53
|
+
//# sourceMappingURL=loadConfig.d.ts.map
|