Skip to content

Commit 7edbeaa

Browse files
Evaluate stored approvals through @intx/authz (#295)
Replace the homegrown glob matcher with matchPattern and route grant checks through evaluateGrants, keeping exact-escaped command grants and the TUI ask path in Corbits.
1 parent ee75800 commit 7edbeaa

8 files changed

Lines changed: 201 additions & 37 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Interchange is the standard library for this repo, consumed as published `@intx/
5454

5555
| Package | Covers |
5656
|---|---|
57-
| `@intx/authz` | Grant-based policy engine (allow/ask/deny) |
57+
| `@intx/authz` | Grant matching (`matchPattern`, `evaluateGrants`) for permission approvals; Corbits owns the gate, store, and TUI ask |
5858
| `@intx/inference` | Reactor loop, `createAuthzExtension`, `DefaultDirector` |
5959
| `@intx/agent` | Agent lifecycle, send queue, stream |
6060
| `@intx/tools-posix` | Shell, file read/write/edit, grep, search |

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,9 @@ tool call
248248
- **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later).
249249
- **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, git worktree add/remove/prune, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`.
250250
- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under `.agent-state` still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted.
251-
- **matcher** — Glob matching of an approval pattern against a request subject.
252-
- **store** — Loads/persists approvals scoped to the working directory.
251+
- **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax).
252+
- **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality.
253+
- **store** — Loads/persists approvals scoped to the working directory (Corbits JSON layout; not the package GrantStore).
253254

254255
**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`, defaults ~11 min). By default (`tools.waitForApproval`, Settings → Tools, **On**), that budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`use-gates`) attaches that signal so ghost prompts cannot outlive an already-aborted tool.
255256
- **types**`Approval`, `ApprovalScope`, `PermissionRequest`, `ApprovalOutcome`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
},
5858
"dependencies": {
5959
"@intx/agent": "0.2.2",
60+
"@intx/authz": "0.2.2",
6061
"@intx/inference": "workspace:*",
6162
"@intx/log": "0.2.2",
6263
"@intx/storage-isogit": "0.2.2",

src/permission/authz-grants.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { evaluateGrants, type GrantRule } from "@intx/authz";
2+
3+
import type { Approval } from "./types.js";
4+
import { matchesPattern } from "./matcher.js";
5+
6+
// Exact-escaped patterns (backslash before metacharacters) cannot round-trip
7+
// through @intx/authz matchPattern, so those grants are filtered out of the
8+
// package call and handled by the exact-equality path in matcher.ts.
9+
function isPackageCompatiblePattern(pattern: string): boolean {
10+
return !pattern.includes("\\");
11+
}
12+
13+
export function approvalToGrantRule(approval: Approval, index: number): GrantRule {
14+
return {
15+
id: `corbits-approval-${index}`,
16+
principalId: null,
17+
roleId: null,
18+
effect: "allow",
19+
origin: "invoker",
20+
// resource = subject pattern (command or path); action = tool name.
21+
resource: approval.pattern,
22+
action: approval.tool,
23+
conditions: null,
24+
expiresAt: null,
25+
};
26+
}
27+
28+
export type EvaluateApprovalsInput = {
29+
tool: string;
30+
subject: string;
31+
approvals: readonly Approval[];
32+
activeProviderModel?: string | undefined;
33+
requestCwd?: string | undefined;
34+
};
35+
36+
// Grant-store evaluation via @intx/authz. Filters provider-model and cwd the
37+
// same way isApproved does, then asks evaluateGrants for the highest-specificity
38+
// allow among package-compatible grants. Exact-escaped grants are checked with
39+
// matchesPattern (equality after unescape) first so a stored exact command is
40+
// never lost.
41+
export async function evaluateApprovals(input: EvaluateApprovalsInput): Promise<boolean> {
42+
const { tool, subject, approvals, activeProviderModel, requestCwd } = input;
43+
const scoped = approvals.filter(
44+
(a) =>
45+
a.tool === tool &&
46+
(a.providerModel === undefined || a.providerModel === activeProviderModel) &&
47+
(a.cwd === undefined || a.cwd === requestCwd),
48+
);
49+
if (scoped.length === 0) return false;
50+
51+
for (const a of scoped) {
52+
if (!isPackageCompatiblePattern(a.pattern) && matchesPattern(subject, a.pattern)) {
53+
return true;
54+
}
55+
}
56+
57+
const grants = scoped
58+
.filter((a) => isPackageCompatiblePattern(a.pattern))
59+
.map((a, i) => approvalToGrantRule(a, i));
60+
if (grants.length === 0) return false;
61+
62+
const decision = await evaluateGrants(grants, subject, tool);
63+
return decision.effect === "allow";
64+
}

src/permission/gate.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
import { autoShellRuleForCall } from "./auto-shell-policy.js";
1414
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
1515
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
16-
import { isApproved, matchesPattern, escapeGlobLiteral } from "./matcher.js";
16+
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
17+
import { evaluateApprovals } from "./authz-grants.js";
1718
import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js";
1819
import { createPathRestriction } from "./path-restriction.js";
1920
import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js";
@@ -421,7 +422,15 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
421422
needsOperator = true;
422423
continue;
423424
}
424-
if (isApproved(request.tool, segment, approvals, activeProviderModel, effectiveCwd)) {
425+
if (
426+
await evaluateApprovals({
427+
tool: request.tool,
428+
subject: segment,
429+
approvals,
430+
activeProviderModel,
431+
requestCwd: effectiveCwd,
432+
})
433+
) {
425434
continue;
426435
}
427436
// Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
@@ -467,10 +476,15 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
467476
continue;
468477
}
469478

470-
const alreadyApproved =
471-
// Path-arg tools already drop to ask via callTargetsRestricted; grants
472-
// match on the path subject the same as before.
473-
isApproved(request.tool, request.subject, approvals, activeProviderModel, effectiveCwd);
479+
// Path-arg tools already drop to ask via callTargetsRestricted; grants
480+
// match on the path subject the same as before.
481+
const alreadyApproved = await evaluateApprovals({
482+
tool: request.tool,
483+
subject: request.subject,
484+
approvals,
485+
activeProviderModel,
486+
requestCwd: effectiveCwd,
487+
});
474488
if (alreadyApproved) {
475489
continue;
476490
}

src/permission/matcher.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,42 @@
1+
import { matchPattern } from "@intx/authz";
2+
13
import type { Approval } from "./types.js";
24

3-
// Translate a shell-style glob (opencode semantics: `*` = zero or more chars,
4-
// `?` = exactly one char, `\x` = literal `x` even when `x` is `*`, `?`, or `\`,
5-
// everything else literal) into an anchored RegExp.
6-
export function globToRegExp(pattern: string): RegExp {
7-
let out = "^";
5+
// Exact-command grants (see escapeGlobLiteral) store a backslash before every
6+
// glob metacharacter so a command like `rm -rf build/*` never becomes the
7+
// wildcard `rm -rf build/*`. @intx/authz's matchPattern has no escape syntax —
8+
// `*` always wildcards — so escaped patterns are exact-only: strip one level of
9+
// backslash escapes and require string equality. Unescaped patterns use the
10+
// package matcher (* wildcards only; no `?`).
11+
function unescapeExactPattern(pattern: string): string {
12+
let out = "";
813
for (let i = 0; i < pattern.length; i++) {
914
const ch = pattern[i] as string;
1015
if (ch === "\\" && i + 1 < pattern.length) {
11-
const escaped = pattern[++i] as string;
12-
out += escaped.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16+
out += pattern[++i] as string;
1317
continue;
1418
}
15-
if (ch === "*") {
16-
out += ".*";
17-
} else if (ch === "?") {
18-
out += ".";
19-
} else {
20-
out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21-
}
19+
out += ch;
2220
}
23-
out += "$";
24-
return new RegExp(out);
21+
return out;
2522
}
2623

27-
// Escape a literal string so it matches only itself when interpreted by
28-
// globToRegExp, even when it contains `*`, `?`, or `\`. Used when a grant must
29-
// cover an exact command rather than a pattern.
24+
function isExactEscapedPattern(pattern: string): boolean {
25+
return pattern.includes("\\");
26+
}
27+
28+
// Escape a literal string so it matches only itself under matchesPattern, even
29+
// when it contains `*`, `?`, or `\`. Used when a grant must cover an exact
30+
// command rather than a wildcard pattern.
3031
export function escapeGlobLiteral(text: string): string {
3132
return text.replace(/[\\*?]/g, "\\$&");
3233
}
3334

3435
export function matchesPattern(subject: string, pattern: string): boolean {
35-
return globToRegExp(pattern).test(subject);
36+
if (isExactEscapedPattern(pattern)) {
37+
return subject === unescapeExactPattern(pattern);
38+
}
39+
return matchPattern(pattern, subject);
3640
}
3741

3842
// True when any stored approval for this tool matches the subject. The subject

src/permission/permission.test.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
isShellNoOp,
1313
stripCommentLines,
1414
} from "./command.js";
15-
import { globToRegExp, matchesPattern, isApproved, escapeGlobLiteral } from "./matcher.js";
15+
import { matchesPattern, isApproved, escapeGlobLiteral } from "./matcher.js";
16+
import { evaluateApprovals } from "./authz-grants.js";
1617
import { classifyTool, buildRequests, isAutoAllowedShellCall } from "./classify.js";
1718
import { createPermissionGate } from "./gate.js";
1819
import { createMcpToolPermissionRegistry, registerMcpClientTools } from "../mcp/tool-permissions.js";
@@ -212,20 +213,20 @@ describe("deriveCommandScopes", () => {
212213
});
213214
});
214215

215-
describe("globToRegExp / matchesPattern", () => {
216-
test("* matches zero or more, ? matches exactly one", () => {
216+
describe("matchesPattern (@intx/authz + exact escapes)", () => {
217+
test("* matches zero or more characters via @intx/authz", () => {
217218
expect(matchesPattern("npm exec vite", "npm *")).toBe(true);
218219
expect(matchesPattern("npm", "npm *")).toBe(false);
219-
expect(matchesPattern("ab", "a?")).toBe(true);
220-
expect(matchesPattern("abc", "a?")).toBe(false);
220+
expect(matchesPattern("src/a.ts", "src/*")).toBe(true);
221+
expect(matchesPattern("lib/a.ts", "src/*")).toBe(false);
221222
});
222223

223-
test("escapes regex metacharacters in literals", () => {
224-
expect(globToRegExp("a.b").test("axb")).toBe(false);
224+
test("literal patterns match only themselves", () => {
225225
expect(matchesPattern("a.b", "a.b")).toBe(true);
226+
expect(matchesPattern("axb", "a.b")).toBe(false);
226227
});
227228

228-
test("a backslash escapes the following char, turning it into a literal", () => {
229+
test("a backslash-escaped pattern is exact-only (package has no escape syntax)", () => {
229230
expect(matchesPattern("echo *", "echo \\*")).toBe(true);
230231
expect(matchesPattern("echo anything", "echo \\*")).toBe(false);
231232
expect(matchesPattern("a?b", "a\\?b")).toBe(true);
@@ -253,6 +254,82 @@ describe("isApproved", () => {
253254
});
254255
});
255256

257+
describe("evaluateApprovals (@intx/authz evaluateGrants)", () => {
258+
const approvals: Approval[] = [
259+
{ tool: "run_shell", pattern: "npm *" },
260+
{ tool: "write_file", pattern: "src/*" },
261+
{ tool: "run_shell", pattern: "rm -rf build/\\*" },
262+
];
263+
264+
test("allows package-compatible wildcard grants", async () => {
265+
expect(
266+
await evaluateApprovals({ tool: "run_shell", subject: "npm test", approvals }),
267+
).toBe(true);
268+
expect(
269+
await evaluateApprovals({ tool: "run_shell", subject: "curl x", approvals }),
270+
).toBe(false);
271+
expect(
272+
await evaluateApprovals({ tool: "write_file", subject: "src/a.ts", approvals }),
273+
).toBe(true);
274+
});
275+
276+
test("allows exact-escaped grants without treating * as a wildcard", async () => {
277+
expect(
278+
await evaluateApprovals({
279+
tool: "run_shell",
280+
subject: "rm -rf build/*",
281+
approvals,
282+
}),
283+
).toBe(true);
284+
expect(
285+
await evaluateApprovals({
286+
tool: "run_shell",
287+
subject: "rm -rf build/../../etc",
288+
approvals,
289+
}),
290+
).toBe(false);
291+
});
292+
293+
test("respects providerModel and cwd filters", async () => {
294+
const scoped: Approval[] = [
295+
{ tool: "run_shell", pattern: "npm *", providerModel: "openai:gpt-4o" },
296+
{ tool: "run_shell", pattern: "git *", cwd: "/repo-a" },
297+
];
298+
expect(
299+
await evaluateApprovals({
300+
tool: "run_shell",
301+
subject: "npm test",
302+
approvals: scoped,
303+
activeProviderModel: "openai:gpt-4o",
304+
}),
305+
).toBe(true);
306+
expect(
307+
await evaluateApprovals({
308+
tool: "run_shell",
309+
subject: "npm test",
310+
approvals: scoped,
311+
activeProviderModel: "anthropic:opus",
312+
}),
313+
).toBe(false);
314+
expect(
315+
await evaluateApprovals({
316+
tool: "run_shell",
317+
subject: "git status",
318+
approvals: scoped,
319+
requestCwd: "/repo-a",
320+
}),
321+
).toBe(true);
322+
expect(
323+
await evaluateApprovals({
324+
tool: "run_shell",
325+
subject: "git status",
326+
approvals: scoped,
327+
requestCwd: "/repo-b",
328+
}),
329+
).toBe(false);
330+
});
331+
});
332+
256333
describe("classifyTool", () => {
257334
test("read-only tools allow, side-effecting tools ask", () => {
258335
expect(classifyTool("read_file")).toBe("allow");

0 commit comments

Comments
 (0)