@nebutra/execution-policy 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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Permission ruleset evaluator.
3
+ *
4
+ * A small, pure, stateless re-expression of a two-dimensional wildcard
5
+ * permission model plus a bash-command-prefix extractor. No global state,
6
+ * no I/O, no mutation of inputs — every function is referentially transparent.
7
+ *
8
+ * The model has two independent dimensions per rule:
9
+ * - `permission` — the capability namespace (e.g. "bash", "edit", "net")
10
+ * - `pattern` — the concrete subject within that namespace
11
+ * A rule applies only when BOTH dimensions match the query via {@link wildcardMatch}.
12
+ */
13
+ /** The decision a rule yields. Unknown queries fail safe to "ask". */
14
+ type Action = "allow" | "deny" | "ask";
15
+ /** A single permission rule. Both dimensions are matched as wildcard globs. */
16
+ interface Rule {
17
+ permission: string;
18
+ pattern: string;
19
+ action: Action;
20
+ }
21
+ /** An ordered list of rules. Earlier rules take precedence. */
22
+ type Ruleset = Rule[];
23
+ /**
24
+ * Anchored full-string glob matcher.
25
+ *
26
+ * Semantics:
27
+ * - `*` matches any run of characters, including the empty run.
28
+ * - `?` matches exactly one character.
29
+ * - Every other character is matched literally (regex metacharacters in
30
+ * `pattern` carry no special meaning).
31
+ * - The match is anchored: the entire `str` must be consumed.
32
+ *
33
+ * Special rule (ported faithfully): if `pattern` ends with `" *"` (a single
34
+ * space immediately followed by `*`), that trailing ` *` is OPTIONAL. The
35
+ * pattern then matches both `"<head> <rest>"` and exactly `"<head>"` with
36
+ * nothing after it. For example `"git *"` matches `"git"` and `"git status"`.
37
+ *
38
+ * Implemented with a backtracking two-pointer scan whose `*` handling uses a
39
+ * single saved restart position, giving O(|str| * |pattern|) worst case with
40
+ * no catastrophic blow-up.
41
+ */
42
+ declare function wildcardMatch(str: string, pattern: string): boolean;
43
+ /**
44
+ * Resolve a permission query against one or more rulesets.
45
+ *
46
+ * Rulesets are concatenated in argument order (no mutation) and scanned
47
+ * front-to-back. The FIRST rule whose `permission` and `pattern` both match
48
+ * the query (via {@link wildcardMatch}) is returned. If no rule matches, a
49
+ * fail-safe default is returned: the queried permission/pattern with action
50
+ * `"ask"` (unknown → ask, never silently allow).
51
+ */
52
+ declare function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule;
53
+ /**
54
+ * Built-in command arity table. Maps a (possibly multi-word) command prefix
55
+ * to the number of leading tokens that constitute its "human-understandable
56
+ * command" for permission matching. Longest matching prefix wins.
57
+ *
58
+ * Frozen so the shared default cannot be mutated by callers.
59
+ */
60
+ declare const BUILTIN_ARITY: Readonly<Record<string, number>>;
61
+ /**
62
+ * Extract the human-understandable command from already-split, flag-free
63
+ * shell tokens.
64
+ *
65
+ * Strategy: try the longest prefix first. For `len` from `tokens.length` down
66
+ * to 1, if `tokens.slice(0, len).join(" ")` is a key in the (merged) arity
67
+ * table, return `tokens.slice(0, arity[thatPrefix])`. If the tokens are empty,
68
+ * return `[]`. Otherwise default to the first token only.
69
+ *
70
+ * `arity` is shallow-merged OVER the built-in table; neither the caller's
71
+ * object nor the built-in table is mutated.
72
+ */
73
+ declare function commandPrefix(tokens: string[], arity?: Record<string, number> | undefined): string[];
74
+ /**
75
+ * Derive the `pattern` to feed {@link evaluate} for a bash permission.
76
+ *
77
+ * Splits `command` on arbitrary whitespace, drops tokens that begin with `-`
78
+ * (flags are not conceptually part of the command identity), applies
79
+ * {@link commandPrefix}, and joins the result with single spaces.
80
+ */
81
+ declare function commandPermissionKey(command: string): string;
82
+ type ShellApprovalMode = "always" | "once_per_session" | "never";
83
+ interface ShellApprovalRule {
84
+ readonly match: string | RegExp;
85
+ readonly requireApproval: ShellApprovalMode;
86
+ readonly reason: string;
87
+ }
88
+ declare const DEFAULT_SHELL_APPROVAL_RULES: readonly ShellApprovalRule[];
89
+ declare function matchesShellApprovalRule(command: string, rule: ShellApprovalRule): boolean;
90
+ declare function shellApprovalRequired(command: string, rules?: readonly ShellApprovalRule[]): ShellApprovalRule | null;
91
+
92
+ export { type Action, BUILTIN_ARITY, DEFAULT_SHELL_APPROVAL_RULES, type Rule, type Ruleset, type ShellApprovalMode, type ShellApprovalRule, commandPermissionKey, commandPrefix, evaluate, matchesShellApprovalRule, shellApprovalRequired, wildcardMatch };
package/dist/index.js ADDED
@@ -0,0 +1,131 @@
1
+ // src/index.ts
2
+ function wildcardMatch(str, pattern) {
3
+ if (isOptionalTrailingStar(pattern)) {
4
+ const head = pattern.slice(0, -2);
5
+ if (globMatch(str, head)) {
6
+ return true;
7
+ }
8
+ return globMatch(str, pattern);
9
+ }
10
+ return globMatch(str, pattern);
11
+ }
12
+ function isOptionalTrailingStar(pattern) {
13
+ return pattern.length >= 2 && pattern.endsWith(" *");
14
+ }
15
+ function globMatch(str, pattern) {
16
+ let s = 0;
17
+ let p = 0;
18
+ let starP = -1;
19
+ let starS = 0;
20
+ while (s < str.length) {
21
+ const pc = p < pattern.length ? pattern[p] : void 0;
22
+ if (pc === "*") {
23
+ starP = p;
24
+ starS = s;
25
+ p += 1;
26
+ } else if (pc === "?" || pc === str[s]) {
27
+ p += 1;
28
+ s += 1;
29
+ } else if (starP !== -1) {
30
+ p = starP + 1;
31
+ starS += 1;
32
+ s = starS;
33
+ } else {
34
+ return false;
35
+ }
36
+ }
37
+ while (p < pattern.length && pattern[p] === "*") {
38
+ p += 1;
39
+ }
40
+ return p === pattern.length;
41
+ }
42
+ function evaluate(permission, pattern, ...rulesets) {
43
+ for (const ruleset of rulesets) {
44
+ for (const rule of ruleset) {
45
+ if (wildcardMatch(permission, rule.permission) && wildcardMatch(pattern, rule.pattern)) {
46
+ return rule;
47
+ }
48
+ }
49
+ }
50
+ return { permission, pattern: "*", action: "ask" };
51
+ }
52
+ var BUILTIN_ARITY = Object.freeze({
53
+ git: 2,
54
+ npm: 2,
55
+ "npm run": 3,
56
+ docker: 2,
57
+ kubectl: 2,
58
+ cargo: 2,
59
+ pnpm: 2,
60
+ "pnpm run": 3,
61
+ yarn: 2,
62
+ "yarn run": 3,
63
+ go: 2,
64
+ ls: 1,
65
+ cat: 1,
66
+ cd: 1,
67
+ rm: 1,
68
+ cp: 1,
69
+ mv: 1,
70
+ mkdir: 1,
71
+ echo: 1,
72
+ grep: 1,
73
+ python: 1,
74
+ node: 1
75
+ });
76
+ function commandPrefix(tokens, arity) {
77
+ if (tokens.length === 0) {
78
+ return [];
79
+ }
80
+ const table = { ...BUILTIN_ARITY, ...arity ?? {} };
81
+ for (let len = tokens.length; len >= 1; len -= 1) {
82
+ const prefixKey = tokens.slice(0, len).join(" ");
83
+ const take = table[prefixKey];
84
+ if (take !== void 0) {
85
+ return tokens.slice(0, Math.min(take, tokens.length));
86
+ }
87
+ }
88
+ return tokens.slice(0, 1);
89
+ }
90
+ function commandPermissionKey(command) {
91
+ const tokens = command.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("-"));
92
+ return commandPrefix(tokens).join(" ");
93
+ }
94
+ var DEFAULT_SHELL_APPROVAL_RULES = Object.freeze([
95
+ { match: /^rm\s+-rf\b/, requireApproval: "always", reason: "destructive recursive removal" },
96
+ {
97
+ match: /\b(format|mkfs)\b/,
98
+ requireApproval: "always",
99
+ reason: "destructive filesystem operation"
100
+ },
101
+ {
102
+ match: /\bDROP\s+(DATABASE|SCHEMA|TABLE)\b/i,
103
+ requireApproval: "always",
104
+ reason: "destructive database operation"
105
+ },
106
+ {
107
+ match: /\b(npm|pnpm|yarn)\s+publish\b/,
108
+ requireApproval: "always",
109
+ reason: "package publishing"
110
+ },
111
+ { match: /^git\s+push\b/, requireApproval: "once_per_session", reason: "remote git mutation" }
112
+ ]);
113
+ function matchesShellApprovalRule(command, rule) {
114
+ return typeof rule.match === "string" ? command.startsWith(rule.match) : rule.match.test(command);
115
+ }
116
+ function shellApprovalRequired(command, rules = DEFAULT_SHELL_APPROVAL_RULES) {
117
+ return rules.find(
118
+ (rule) => rule.requireApproval !== "never" && matchesShellApprovalRule(command, rule)
119
+ ) ?? null;
120
+ }
121
+ export {
122
+ BUILTIN_ARITY,
123
+ DEFAULT_SHELL_APPROVAL_RULES,
124
+ commandPermissionKey,
125
+ commandPrefix,
126
+ evaluate,
127
+ matchesShellApprovalRule,
128
+ shellApprovalRequired,
129
+ wildcardMatch
130
+ };
131
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Permission ruleset evaluator.\n *\n * A small, pure, stateless re-expression of a two-dimensional wildcard\n * permission model plus a bash-command-prefix extractor. No global state,\n * no I/O, no mutation of inputs — every function is referentially transparent.\n *\n * The model has two independent dimensions per rule:\n * - `permission` — the capability namespace (e.g. \"bash\", \"edit\", \"net\")\n * - `pattern` — the concrete subject within that namespace\n * A rule applies only when BOTH dimensions match the query via {@link wildcardMatch}.\n */\n\n/** The decision a rule yields. Unknown queries fail safe to \"ask\". */\nexport type Action = \"allow\" | \"deny\" | \"ask\";\n\n/** A single permission rule. Both dimensions are matched as wildcard globs. */\nexport interface Rule {\n permission: string;\n pattern: string;\n action: Action;\n}\n\n/** An ordered list of rules. Earlier rules take precedence. */\nexport type Ruleset = Rule[];\n\n/**\n * Anchored full-string glob matcher.\n *\n * Semantics:\n * - `*` matches any run of characters, including the empty run.\n * - `?` matches exactly one character.\n * - Every other character is matched literally (regex metacharacters in\n * `pattern` carry no special meaning).\n * - The match is anchored: the entire `str` must be consumed.\n *\n * Special rule (ported faithfully): if `pattern` ends with `\" *\"` (a single\n * space immediately followed by `*`), that trailing ` *` is OPTIONAL. The\n * pattern then matches both `\"<head> <rest>\"` and exactly `\"<head>\"` with\n * nothing after it. For example `\"git *\"` matches `\"git\"` and `\"git status\"`.\n *\n * Implemented with a backtracking two-pointer scan whose `*` handling uses a\n * single saved restart position, giving O(|str| * |pattern|) worst case with\n * no catastrophic blow-up.\n */\nexport function wildcardMatch(str: string, pattern: string): boolean {\n if (isOptionalTrailingStar(pattern)) {\n const head = pattern.slice(0, -2); // drop the trailing \" *\"\n // Head-only branch: the whole string equals the head, matched as a glob.\n if (globMatch(str, head)) {\n return true;\n }\n // Otherwise the full \"<head> *\" pattern must match (space is consumed).\n return globMatch(str, pattern);\n }\n return globMatch(str, pattern);\n}\n\n/**\n * True when `pattern` ends in a literal space followed by `*`, and that `*`\n * is the final character. A bare `\"*\"` (no preceding space) is NOT treated\n * as the optional-suffix form.\n */\nfunction isOptionalTrailingStar(pattern: string): boolean {\n return pattern.length >= 2 && pattern.endsWith(\" *\");\n}\n\n/**\n * Core anchored glob match using linear-time backtracking. Only `*` can\n * backtrack, and it uses a single restart marker (the classic two-pointer\n * algorithm), so there is no exponential backtracking.\n */\nfunction globMatch(str: string, pattern: string): boolean {\n let s = 0;\n let p = 0;\n let starP = -1;\n let starS = 0;\n\n while (s < str.length) {\n const pc = p < pattern.length ? pattern[p] : undefined;\n if (pc === \"*\") {\n // Record the restart point and tentatively consume zero chars.\n starP = p;\n starS = s;\n p += 1;\n } else if (pc === \"?\" || pc === str[s]) {\n p += 1;\n s += 1;\n } else if (starP !== -1) {\n // Backtrack: let the last `*` swallow one more character.\n p = starP + 1;\n starS += 1;\n s = starS;\n } else {\n return false;\n }\n }\n\n // Consume any trailing `*` segments in the pattern.\n while (p < pattern.length && pattern[p] === \"*\") {\n p += 1;\n }\n\n return p === pattern.length;\n}\n\n/**\n * Resolve a permission query against one or more rulesets.\n *\n * Rulesets are concatenated in argument order (no mutation) and scanned\n * front-to-back. The FIRST rule whose `permission` and `pattern` both match\n * the query (via {@link wildcardMatch}) is returned. If no rule matches, a\n * fail-safe default is returned: the queried permission/pattern with action\n * `\"ask\"` (unknown → ask, never silently allow).\n */\nexport function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {\n for (const ruleset of rulesets) {\n for (const rule of ruleset) {\n if (wildcardMatch(permission, rule.permission) && wildcardMatch(pattern, rule.pattern)) {\n return rule;\n }\n }\n }\n return { permission, pattern: \"*\", action: \"ask\" };\n}\n\n/**\n * Built-in command arity table. Maps a (possibly multi-word) command prefix\n * to the number of leading tokens that constitute its \"human-understandable\n * command\" for permission matching. Longest matching prefix wins.\n *\n * Frozen so the shared default cannot be mutated by callers.\n */\nexport const BUILTIN_ARITY: Readonly<Record<string, number>> = Object.freeze({\n git: 2,\n npm: 2,\n \"npm run\": 3,\n docker: 2,\n kubectl: 2,\n cargo: 2,\n pnpm: 2,\n \"pnpm run\": 3,\n yarn: 2,\n \"yarn run\": 3,\n go: 2,\n ls: 1,\n cat: 1,\n cd: 1,\n rm: 1,\n cp: 1,\n mv: 1,\n mkdir: 1,\n echo: 1,\n grep: 1,\n python: 1,\n node: 1,\n});\n\n/**\n * Extract the human-understandable command from already-split, flag-free\n * shell tokens.\n *\n * Strategy: try the longest prefix first. For `len` from `tokens.length` down\n * to 1, if `tokens.slice(0, len).join(\" \")` is a key in the (merged) arity\n * table, return `tokens.slice(0, arity[thatPrefix])`. If the tokens are empty,\n * return `[]`. Otherwise default to the first token only.\n *\n * `arity` is shallow-merged OVER the built-in table; neither the caller's\n * object nor the built-in table is mutated.\n */\nexport function commandPrefix(\n tokens: string[],\n arity?: Record<string, number> | undefined,\n): string[] {\n if (tokens.length === 0) {\n return [];\n }\n const table: Record<string, number> = { ...BUILTIN_ARITY, ...(arity ?? {}) };\n\n for (let len = tokens.length; len >= 1; len -= 1) {\n const prefixKey = tokens.slice(0, len).join(\" \");\n const take = table[prefixKey];\n if (take !== undefined) {\n // Clamp to the available token count; never expand beyond the input.\n return tokens.slice(0, Math.min(take, tokens.length));\n }\n }\n\n return tokens.slice(0, 1);\n}\n\n/**\n * Derive the `pattern` to feed {@link evaluate} for a bash permission.\n *\n * Splits `command` on arbitrary whitespace, drops tokens that begin with `-`\n * (flags are not conceptually part of the command identity), applies\n * {@link commandPrefix}, and joins the result with single spaces.\n */\nexport function commandPermissionKey(command: string): string {\n const tokens = command.split(/\\s+/).filter((token) => token.length > 0 && !token.startsWith(\"-\"));\n return commandPrefix(tokens).join(\" \");\n}\n\nexport type ShellApprovalMode = \"always\" | \"once_per_session\" | \"never\";\n\nexport interface ShellApprovalRule {\n readonly match: string | RegExp;\n readonly requireApproval: ShellApprovalMode;\n readonly reason: string;\n}\n\nexport const DEFAULT_SHELL_APPROVAL_RULES: readonly ShellApprovalRule[] = Object.freeze([\n { match: /^rm\\s+-rf\\b/, requireApproval: \"always\", reason: \"destructive recursive removal\" },\n {\n match: /\\b(format|mkfs)\\b/,\n requireApproval: \"always\",\n reason: \"destructive filesystem operation\",\n },\n {\n match: /\\bDROP\\s+(DATABASE|SCHEMA|TABLE)\\b/i,\n requireApproval: \"always\",\n reason: \"destructive database operation\",\n },\n {\n match: /\\b(npm|pnpm|yarn)\\s+publish\\b/,\n requireApproval: \"always\",\n reason: \"package publishing\",\n },\n { match: /^git\\s+push\\b/, requireApproval: \"once_per_session\", reason: \"remote git mutation\" },\n]);\n\nexport function matchesShellApprovalRule(command: string, rule: ShellApprovalRule): boolean {\n return typeof rule.match === \"string\" ? command.startsWith(rule.match) : rule.match.test(command);\n}\n\nexport function shellApprovalRequired(\n command: string,\n rules: readonly ShellApprovalRule[] = DEFAULT_SHELL_APPROVAL_RULES,\n): ShellApprovalRule | null {\n return (\n rules.find(\n (rule) => rule.requireApproval !== \"never\" && matchesShellApprovalRule(command, rule),\n ) ?? null\n );\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@nebutra/execution-policy",
3
+ "version": "0.1.0",
4
+ "description": "Shared command permission and approval policy primitives for runtime and execution packages",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "nebutra": {
9
+ "status": "wip",
10
+ "productionReady": false,
11
+ "surface": "support-contract",
12
+ "requires": [
13
+ "@nebutra/agent-runtime consumes this contract for runtime approval grammar",
14
+ "@nebutra/code-execution consumes this contract for executor policy checks"
15
+ ],
16
+ "gaps": [
17
+ "Session-scoped approval persistence still lives in agent-runtime",
18
+ "Shell parsing remains prefix-based until a shell AST parser is introduced",
19
+ "Network and file-write policies are not unified here yet"
20
+ ],
21
+ "featureId": "execution-policy",
22
+ "category": "ai",
23
+ "summary": "Single shared owner for command permission matching and shell approval defaults"
24
+ },
25
+ "main": "./src/index.ts",
26
+ "types": "./src/index.ts",
27
+ "exports": {
28
+ ".": "./src/index.ts"
29
+ },
30
+ "dependencies": {},
31
+ "devDependencies": {
32
+ "@types/node": "^22.19.15",
33
+ "tsup": "^8.5.1",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^4.1.4"
36
+ },
37
+ "homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/ai/execution-policy#readme",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
41
+ "directory": "packages/ai/execution-policy"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "test": "vitest run",
52
+ "typecheck": "tsc --noEmit"
53
+ }
54
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type Action,
4
+ BUILTIN_ARITY,
5
+ commandPermissionKey,
6
+ commandPrefix,
7
+ DEFAULT_SHELL_APPROVAL_RULES,
8
+ evaluate,
9
+ type Rule,
10
+ type Ruleset,
11
+ shellApprovalRequired,
12
+ wildcardMatch,
13
+ } from "./index";
14
+
15
+ describe("wildcardMatch", () => {
16
+ it("matches anchored globs with optional trailing space-star", () => {
17
+ expect(wildcardMatch("git", "git")).toBe(true);
18
+ expect(wildcardMatch("git status", "git")).toBe(false);
19
+ expect(wildcardMatch("git", "git *")).toBe(true);
20
+ expect(wildcardMatch("git status", "git *")).toBe(true);
21
+ expect(wildcardMatch("npm", "git *")).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe("evaluate", () => {
26
+ it("resolves first matching permission and pattern pair", () => {
27
+ const allowGitRead: Rule = { permission: "bash", pattern: "git status", action: "allow" };
28
+ const askGit: Rule = { permission: "bash", pattern: "git *", action: "ask" };
29
+ const denyRm: Rule = { permission: "bash", pattern: "rm *", action: "deny" };
30
+ const ruleset: Ruleset = [allowGitRead, askGit, denyRm];
31
+
32
+ expect(evaluate("bash", "git status", ruleset)).toEqual(allowGitRead);
33
+ expect(evaluate("bash", "git push", ruleset)).toEqual(askGit);
34
+ expect(evaluate("edit", "git status", ruleset)).toEqual({
35
+ permission: "edit",
36
+ pattern: "*",
37
+ action: "ask",
38
+ });
39
+ });
40
+
41
+ it("keeps the documented action vocabulary", () => {
42
+ const actions: Action[] = ["allow", "deny", "ask"];
43
+ expect(actions).toHaveLength(3);
44
+ });
45
+ });
46
+
47
+ describe("commandPermissionKey", () => {
48
+ it("uses the shared command arity table", () => {
49
+ expect(BUILTIN_ARITY.git).toBe(2);
50
+ expect(commandPrefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
51
+ expect(commandPermissionKey("pnpm run test -- --watch")).toBe("pnpm run test");
52
+ expect(commandPermissionKey("rm -rf build")).toBe("rm");
53
+ });
54
+ });
55
+
56
+ describe("shellApprovalRequired", () => {
57
+ it("keeps destructive defaults in the shared execution policy contract", () => {
58
+ expect(DEFAULT_SHELL_APPROVAL_RULES.length).toBeGreaterThanOrEqual(5);
59
+ expect(shellApprovalRequired("rm -rf build")).toMatchObject({
60
+ requireApproval: "always",
61
+ reason: "destructive recursive removal",
62
+ });
63
+ expect(shellApprovalRequired("git push origin main")).toMatchObject({
64
+ requireApproval: "once_per_session",
65
+ });
66
+ expect(shellApprovalRequired("pnpm test")).toBeNull();
67
+ });
68
+ });
package/src/index.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Permission ruleset evaluator.
3
+ *
4
+ * A small, pure, stateless re-expression of a two-dimensional wildcard
5
+ * permission model plus a bash-command-prefix extractor. No global state,
6
+ * no I/O, no mutation of inputs — every function is referentially transparent.
7
+ *
8
+ * The model has two independent dimensions per rule:
9
+ * - `permission` — the capability namespace (e.g. "bash", "edit", "net")
10
+ * - `pattern` — the concrete subject within that namespace
11
+ * A rule applies only when BOTH dimensions match the query via {@link wildcardMatch}.
12
+ */
13
+
14
+ /** The decision a rule yields. Unknown queries fail safe to "ask". */
15
+ export type Action = "allow" | "deny" | "ask";
16
+
17
+ /** A single permission rule. Both dimensions are matched as wildcard globs. */
18
+ export interface Rule {
19
+ permission: string;
20
+ pattern: string;
21
+ action: Action;
22
+ }
23
+
24
+ /** An ordered list of rules. Earlier rules take precedence. */
25
+ export type Ruleset = Rule[];
26
+
27
+ /**
28
+ * Anchored full-string glob matcher.
29
+ *
30
+ * Semantics:
31
+ * - `*` matches any run of characters, including the empty run.
32
+ * - `?` matches exactly one character.
33
+ * - Every other character is matched literally (regex metacharacters in
34
+ * `pattern` carry no special meaning).
35
+ * - The match is anchored: the entire `str` must be consumed.
36
+ *
37
+ * Special rule (ported faithfully): if `pattern` ends with `" *"` (a single
38
+ * space immediately followed by `*`), that trailing ` *` is OPTIONAL. The
39
+ * pattern then matches both `"<head> <rest>"` and exactly `"<head>"` with
40
+ * nothing after it. For example `"git *"` matches `"git"` and `"git status"`.
41
+ *
42
+ * Implemented with a backtracking two-pointer scan whose `*` handling uses a
43
+ * single saved restart position, giving O(|str| * |pattern|) worst case with
44
+ * no catastrophic blow-up.
45
+ */
46
+ export function wildcardMatch(str: string, pattern: string): boolean {
47
+ if (isOptionalTrailingStar(pattern)) {
48
+ const head = pattern.slice(0, -2); // drop the trailing " *"
49
+ // Head-only branch: the whole string equals the head, matched as a glob.
50
+ if (globMatch(str, head)) {
51
+ return true;
52
+ }
53
+ // Otherwise the full "<head> *" pattern must match (space is consumed).
54
+ return globMatch(str, pattern);
55
+ }
56
+ return globMatch(str, pattern);
57
+ }
58
+
59
+ /**
60
+ * True when `pattern` ends in a literal space followed by `*`, and that `*`
61
+ * is the final character. A bare `"*"` (no preceding space) is NOT treated
62
+ * as the optional-suffix form.
63
+ */
64
+ function isOptionalTrailingStar(pattern: string): boolean {
65
+ return pattern.length >= 2 && pattern.endsWith(" *");
66
+ }
67
+
68
+ /**
69
+ * Core anchored glob match using linear-time backtracking. Only `*` can
70
+ * backtrack, and it uses a single restart marker (the classic two-pointer
71
+ * algorithm), so there is no exponential backtracking.
72
+ */
73
+ function globMatch(str: string, pattern: string): boolean {
74
+ let s = 0;
75
+ let p = 0;
76
+ let starP = -1;
77
+ let starS = 0;
78
+
79
+ while (s < str.length) {
80
+ const pc = p < pattern.length ? pattern[p] : undefined;
81
+ if (pc === "*") {
82
+ // Record the restart point and tentatively consume zero chars.
83
+ starP = p;
84
+ starS = s;
85
+ p += 1;
86
+ } else if (pc === "?" || pc === str[s]) {
87
+ p += 1;
88
+ s += 1;
89
+ } else if (starP !== -1) {
90
+ // Backtrack: let the last `*` swallow one more character.
91
+ p = starP + 1;
92
+ starS += 1;
93
+ s = starS;
94
+ } else {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // Consume any trailing `*` segments in the pattern.
100
+ while (p < pattern.length && pattern[p] === "*") {
101
+ p += 1;
102
+ }
103
+
104
+ return p === pattern.length;
105
+ }
106
+
107
+ /**
108
+ * Resolve a permission query against one or more rulesets.
109
+ *
110
+ * Rulesets are concatenated in argument order (no mutation) and scanned
111
+ * front-to-back. The FIRST rule whose `permission` and `pattern` both match
112
+ * the query (via {@link wildcardMatch}) is returned. If no rule matches, a
113
+ * fail-safe default is returned: the queried permission/pattern with action
114
+ * `"ask"` (unknown → ask, never silently allow).
115
+ */
116
+ export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
117
+ for (const ruleset of rulesets) {
118
+ for (const rule of ruleset) {
119
+ if (wildcardMatch(permission, rule.permission) && wildcardMatch(pattern, rule.pattern)) {
120
+ return rule;
121
+ }
122
+ }
123
+ }
124
+ return { permission, pattern: "*", action: "ask" };
125
+ }
126
+
127
+ /**
128
+ * Built-in command arity table. Maps a (possibly multi-word) command prefix
129
+ * to the number of leading tokens that constitute its "human-understandable
130
+ * command" for permission matching. Longest matching prefix wins.
131
+ *
132
+ * Frozen so the shared default cannot be mutated by callers.
133
+ */
134
+ export const BUILTIN_ARITY: Readonly<Record<string, number>> = Object.freeze({
135
+ git: 2,
136
+ npm: 2,
137
+ "npm run": 3,
138
+ docker: 2,
139
+ kubectl: 2,
140
+ cargo: 2,
141
+ pnpm: 2,
142
+ "pnpm run": 3,
143
+ yarn: 2,
144
+ "yarn run": 3,
145
+ go: 2,
146
+ ls: 1,
147
+ cat: 1,
148
+ cd: 1,
149
+ rm: 1,
150
+ cp: 1,
151
+ mv: 1,
152
+ mkdir: 1,
153
+ echo: 1,
154
+ grep: 1,
155
+ python: 1,
156
+ node: 1,
157
+ });
158
+
159
+ /**
160
+ * Extract the human-understandable command from already-split, flag-free
161
+ * shell tokens.
162
+ *
163
+ * Strategy: try the longest prefix first. For `len` from `tokens.length` down
164
+ * to 1, if `tokens.slice(0, len).join(" ")` is a key in the (merged) arity
165
+ * table, return `tokens.slice(0, arity[thatPrefix])`. If the tokens are empty,
166
+ * return `[]`. Otherwise default to the first token only.
167
+ *
168
+ * `arity` is shallow-merged OVER the built-in table; neither the caller's
169
+ * object nor the built-in table is mutated.
170
+ */
171
+ export function commandPrefix(
172
+ tokens: string[],
173
+ arity?: Record<string, number> | undefined,
174
+ ): string[] {
175
+ if (tokens.length === 0) {
176
+ return [];
177
+ }
178
+ const table: Record<string, number> = { ...BUILTIN_ARITY, ...(arity ?? {}) };
179
+
180
+ for (let len = tokens.length; len >= 1; len -= 1) {
181
+ const prefixKey = tokens.slice(0, len).join(" ");
182
+ const take = table[prefixKey];
183
+ if (take !== undefined) {
184
+ // Clamp to the available token count; never expand beyond the input.
185
+ return tokens.slice(0, Math.min(take, tokens.length));
186
+ }
187
+ }
188
+
189
+ return tokens.slice(0, 1);
190
+ }
191
+
192
+ /**
193
+ * Derive the `pattern` to feed {@link evaluate} for a bash permission.
194
+ *
195
+ * Splits `command` on arbitrary whitespace, drops tokens that begin with `-`
196
+ * (flags are not conceptually part of the command identity), applies
197
+ * {@link commandPrefix}, and joins the result with single spaces.
198
+ */
199
+ export function commandPermissionKey(command: string): string {
200
+ const tokens = command.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("-"));
201
+ return commandPrefix(tokens).join(" ");
202
+ }
203
+
204
+ export type ShellApprovalMode = "always" | "once_per_session" | "never";
205
+
206
+ export interface ShellApprovalRule {
207
+ readonly match: string | RegExp;
208
+ readonly requireApproval: ShellApprovalMode;
209
+ readonly reason: string;
210
+ }
211
+
212
+ export const DEFAULT_SHELL_APPROVAL_RULES: readonly ShellApprovalRule[] = Object.freeze([
213
+ { match: /^rm\s+-rf\b/, requireApproval: "always", reason: "destructive recursive removal" },
214
+ {
215
+ match: /\b(format|mkfs)\b/,
216
+ requireApproval: "always",
217
+ reason: "destructive filesystem operation",
218
+ },
219
+ {
220
+ match: /\bDROP\s+(DATABASE|SCHEMA|TABLE)\b/i,
221
+ requireApproval: "always",
222
+ reason: "destructive database operation",
223
+ },
224
+ {
225
+ match: /\b(npm|pnpm|yarn)\s+publish\b/,
226
+ requireApproval: "always",
227
+ reason: "package publishing",
228
+ },
229
+ { match: /^git\s+push\b/, requireApproval: "once_per_session", reason: "remote git mutation" },
230
+ ]);
231
+
232
+ export function matchesShellApprovalRule(command: string, rule: ShellApprovalRule): boolean {
233
+ return typeof rule.match === "string" ? command.startsWith(rule.match) : rule.match.test(command);
234
+ }
235
+
236
+ export function shellApprovalRequired(
237
+ command: string,
238
+ rules: readonly ShellApprovalRule[] = DEFAULT_SHELL_APPROVAL_RULES,
239
+ ): ShellApprovalRule | null {
240
+ return (
241
+ rules.find(
242
+ (rule) => rule.requireApproval !== "never" && matchesShellApprovalRule(command, rule),
243
+ ) ?? null
244
+ );
245
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "target": "esnext",
7
+ "types": ["node"],
8
+ "incremental": false
9
+ },
10
+ "include": ["src"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm"],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ target: "es2022",
10
+ });