dsh-perm-gate 2.0.0 → 2.1.2
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/CHANGELOG.md +111 -0
- package/INSTALL.ja.md +57 -1
- package/INSTALL.ko.md +54 -1
- package/INSTALL.md +53 -1
- package/INSTALL.zh.md +48 -1
- package/README.ja.md +86 -12
- package/README.ko.md +83 -12
- package/README.md +120 -24
- package/README.zh.md +88 -10
- package/cordis.patch.yml +34 -2
- package/dsh.plugin.json +22 -0
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/client.js +114 -0
- package/lib/client.js.map +1 -1
- package/lib/compiler.d.ts +39 -0
- package/lib/compiler.js +106 -0
- package/lib/config.d.ts +95 -2
- package/lib/config.js +28 -3
- package/lib/engine.js +9 -2
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +189 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +31 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +235 -6
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.js +25 -3
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +222 -0
- package/lib/rule-dims.d.ts +139 -0
- package/lib/rule-dims.js +194 -0
- package/lib/rule.d.ts +25 -0
- package/lib/rule.js +15 -1
- package/lib/runtime.d.ts +87 -0
- package/lib/runtime.js +327 -2
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +8 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-file rule chain for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Resolves and merges multiple YAML permissions documents into a single
|
|
5
|
+
* `CompiledRuleset`. The chain follows a search-up strategy: starting from
|
|
6
|
+
* the workspace cwd, walk up directory ancestors looking for the rules file,
|
|
7
|
+
* then fall back to a configured path, then to an empty ruleset.
|
|
8
|
+
*
|
|
9
|
+
* Each file in the chain is independently compiled and cached by content hash.
|
|
10
|
+
* Chain-level merge preserves the deny-first partitioned structure: all deny
|
|
11
|
+
* entries from all files are concatenated (with source file tracking), then
|
|
12
|
+
* all allow entries, then all ask entries.
|
|
13
|
+
*
|
|
14
|
+
* Error policy: `fail` (default) throws on any malformed file; `warn` logs
|
|
15
|
+
* and skips the bad file, keeping the rest of the chain.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { existsSync, statSync } from 'node:fs';
|
|
20
|
+
import { parsePermissionsDocument, compileDocument, documentHash } from './rule.js';
|
|
21
|
+
import { hashText } from './compiler.js';
|
|
22
|
+
/** Compile-cache entry: hash → compiled ruleset. */
|
|
23
|
+
const compileCache = new Map();
|
|
24
|
+
// ─── Chain resolution ──────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the rule chain: find all applicable rules files and merge them.
|
|
27
|
+
*
|
|
28
|
+
* @param cwd - The workspace root directory.
|
|
29
|
+
* @param config - Chain configuration.
|
|
30
|
+
* @param opts - Compile options forwarded to each file's compiler.
|
|
31
|
+
* @returns The merged compiled ruleset.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRuleChain(cwd, config, opts = {}) {
|
|
34
|
+
const { rulesFile, searchUp = false, fallbackPath, badFilePolicy = 'fail', maxChainLength = 10, } = config;
|
|
35
|
+
const entries = findChainEntries(cwd, rulesFile, searchUp, fallbackPath, maxChainLength);
|
|
36
|
+
if (entries.length === 0) {
|
|
37
|
+
// Empty chain: return a ruleset with no rules (defaultAction only).
|
|
38
|
+
return {
|
|
39
|
+
defaultAction: 'ask',
|
|
40
|
+
deny: [],
|
|
41
|
+
allow: [],
|
|
42
|
+
ask: [],
|
|
43
|
+
caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Merge all entries into a single ruleset.
|
|
47
|
+
return mergeChain(entries, badFilePolicy, opts);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Walk the chain and collect valid rules file entries.
|
|
51
|
+
*/
|
|
52
|
+
function findChainEntries(cwd, rulesFile, searchUp, fallbackPath, maxChainLength) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
// 1. Start from cwd and optionally search up.
|
|
56
|
+
const candidates = [];
|
|
57
|
+
let dir = resolve(cwd);
|
|
58
|
+
// Always check cwd first.
|
|
59
|
+
candidates.push(join(dir, rulesFile));
|
|
60
|
+
if (searchUp) {
|
|
61
|
+
// Walk up ancestors until root.
|
|
62
|
+
let prev = '';
|
|
63
|
+
while (dir !== prev && candidates.length < maxChainLength) {
|
|
64
|
+
candidates.push(join(dir, rulesFile));
|
|
65
|
+
prev = dir;
|
|
66
|
+
dir = dirname(dir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
if (entries.length >= maxChainLength)
|
|
71
|
+
break;
|
|
72
|
+
const resolved = resolve(candidate);
|
|
73
|
+
if (seen.has(resolved))
|
|
74
|
+
continue;
|
|
75
|
+
seen.add(resolved);
|
|
76
|
+
if (!existsSafe(resolved))
|
|
77
|
+
continue;
|
|
78
|
+
const stat = statSafe(resolved);
|
|
79
|
+
if (stat === undefined || !stat.isFile())
|
|
80
|
+
continue;
|
|
81
|
+
const entry = readEntry(resolved);
|
|
82
|
+
if (entry !== undefined)
|
|
83
|
+
entries.push(entry);
|
|
84
|
+
}
|
|
85
|
+
// 2. Fallback path.
|
|
86
|
+
if (fallbackPath !== undefined && entries.length === 0) {
|
|
87
|
+
const resolved = resolve(fallbackPath);
|
|
88
|
+
if (!seen.has(resolved) && existsSafe(resolved)) {
|
|
89
|
+
const entry = readEntry(resolved);
|
|
90
|
+
if (entry !== undefined)
|
|
91
|
+
entries.push(entry);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read and parse a single rules file into a ChainEntry.
|
|
98
|
+
*/
|
|
99
|
+
function readEntry(filePath) {
|
|
100
|
+
try {
|
|
101
|
+
const text = readFileSync(filePath, 'utf8');
|
|
102
|
+
const hash = documentHash(text);
|
|
103
|
+
const doc = parsePermissionsDocument(text);
|
|
104
|
+
return { path: filePath, hash, doc };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Merge a chain of entries into a single CompiledRuleset.
|
|
112
|
+
* Each file's entries are sorted into deny/allow/ask partitions.
|
|
113
|
+
* Chain-level order: deny (all files, in chain order) → allow → ask.
|
|
114
|
+
*/
|
|
115
|
+
function mergeChain(entries, badFilePolicy, opts) {
|
|
116
|
+
// Collect entries per action, preserving chain order.
|
|
117
|
+
const denyDocs = [];
|
|
118
|
+
const allowDocs = [];
|
|
119
|
+
const askDocs = [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
try {
|
|
122
|
+
// Compile to validate patterns (throws on malformed input).
|
|
123
|
+
compileDocument(entry.doc, opts);
|
|
124
|
+
// We use the raw doc lists for the merge (CompiledRuleset structure
|
|
125
|
+
// expects flat lists with source tracking).
|
|
126
|
+
denyDocs.push(entry.doc);
|
|
127
|
+
allowDocs.push(entry.doc);
|
|
128
|
+
askDocs.push(entry.doc);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (badFilePolicy === 'fail')
|
|
132
|
+
throw err;
|
|
133
|
+
// warn: skip this file silently.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Flatten: deny entries from all files, then allow, then ask.
|
|
137
|
+
// Index numbering is sequential across the entire chain.
|
|
138
|
+
let index = 0;
|
|
139
|
+
const deny = flattenEntries(denyDocs, 'deny', index);
|
|
140
|
+
index += deny.length;
|
|
141
|
+
const allow = flattenEntries(allowDocs, 'allow', index);
|
|
142
|
+
index += allow.length;
|
|
143
|
+
const ask = flattenEntries(askDocs, 'ask', index);
|
|
144
|
+
return {
|
|
145
|
+
defaultAction: entries.length > 0 ? entries[0].doc.defaultAction : 'ask',
|
|
146
|
+
deny,
|
|
147
|
+
allow,
|
|
148
|
+
ask,
|
|
149
|
+
caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Flatten a list of PermissionDocs into a single list of CompiledRuleEntry
|
|
154
|
+
* for a specific action partition.
|
|
155
|
+
*/
|
|
156
|
+
function flattenEntries(docs, action, startIndex) {
|
|
157
|
+
const result = [];
|
|
158
|
+
let idx = startIndex;
|
|
159
|
+
for (const doc of docs) {
|
|
160
|
+
const list = action === 'deny' ? doc.deny : action === 'allow' ? doc.allow : doc.ask;
|
|
161
|
+
for (const entry of list) {
|
|
162
|
+
result.push({
|
|
163
|
+
index: idx++,
|
|
164
|
+
action,
|
|
165
|
+
reason: entry.reason,
|
|
166
|
+
enabled: entry.enabled,
|
|
167
|
+
tools: [], // Will be compiled in the final merge step
|
|
168
|
+
command: [],
|
|
169
|
+
args: [],
|
|
170
|
+
paths: [],
|
|
171
|
+
params: entry.params,
|
|
172
|
+
absent: entry.absent,
|
|
173
|
+
agents: entry.agents,
|
|
174
|
+
when: entry.when,
|
|
175
|
+
argv: entry.argv,
|
|
176
|
+
network: entry.network,
|
|
177
|
+
source: entry,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
// ─── Cache management ──────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Get or create a compiled ruleset from the cache.
|
|
186
|
+
* Returns the cached version if the hash matches; otherwise compiles fresh.
|
|
187
|
+
*/
|
|
188
|
+
export function cachedCompile(text, opts = {}) {
|
|
189
|
+
const hash = hashText(text);
|
|
190
|
+
const cached = compileCache.get(hash);
|
|
191
|
+
if (cached !== undefined)
|
|
192
|
+
return cached;
|
|
193
|
+
const doc = parsePermissionsDocument(text);
|
|
194
|
+
const compiled = compileDocument(doc, opts);
|
|
195
|
+
compileCache.set(hash, compiled);
|
|
196
|
+
return compiled;
|
|
197
|
+
}
|
|
198
|
+
/** Clear the compile cache (used in tests and on reload). */
|
|
199
|
+
export function clearCompileCache() {
|
|
200
|
+
compileCache.clear();
|
|
201
|
+
}
|
|
202
|
+
/** Current compile cache size (diagnostics). */
|
|
203
|
+
export function compileCacheSize() {
|
|
204
|
+
return compileCache.size;
|
|
205
|
+
}
|
|
206
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
207
|
+
function existsSafe(path) {
|
|
208
|
+
try {
|
|
209
|
+
return existsSync(path);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function statSafe(path) {
|
|
216
|
+
try {
|
|
217
|
+
return statSync(path);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended rule dimensions for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* These 6 dimensions extend the existing 4 (tools/command/args/paths) and are
|
|
5
|
+
* parsed from each rule entry in the YAML permissions document. All types are
|
|
6
|
+
* pure — no I/O — so parsing and validation are unit-testable.
|
|
7
|
+
*
|
|
8
|
+
* Dimensions:
|
|
9
|
+
* 1. params — key→value glob matching (AND over keys, `!` prefix negates)
|
|
10
|
+
* 2. absent — parameter keys that must NOT be present
|
|
11
|
+
* 3. agents — agent identity candidates (main/subagent/preset:<name>)
|
|
12
|
+
* 4. when — environment / platform conditions
|
|
13
|
+
* 5. argv — extra argv patterns (pipeline, etc.) not covered by command/args
|
|
14
|
+
* 6. network — domain / IP / port / scheme matching
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* One parameter match condition: a key name mapped to a list of value glob
|
|
18
|
+
* patterns. The key's actual value must match at least one pattern (OR within
|
|
19
|
+
* the list). All keys must match (AND across keys).
|
|
20
|
+
*
|
|
21
|
+
* A pattern prefixed with `!` is negated: the value must NOT match the
|
|
22
|
+
* remainder of the pattern.
|
|
23
|
+
*/
|
|
24
|
+
export interface ParamCondition {
|
|
25
|
+
/** The parameter key name (e.g. `command`, `flags.mode`). */
|
|
26
|
+
readonly key: string;
|
|
27
|
+
/** Value glob patterns; `!` prefix = negation. Empty list = key must exist (any value). */
|
|
28
|
+
readonly patterns: readonly string[];
|
|
29
|
+
/** Whether this condition is a negation (derived from `!` prefix on the sole pattern). */
|
|
30
|
+
readonly negated: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parsed `params` dimension: a list of key→value conditions evaluated as AND.
|
|
34
|
+
*/
|
|
35
|
+
export type ParamsDimension = readonly ParamCondition[];
|
|
36
|
+
/**
|
|
37
|
+
* Parse the `params` field from a rule entry.
|
|
38
|
+
*
|
|
39
|
+
* Format:
|
|
40
|
+
* ```yaml
|
|
41
|
+
* params:
|
|
42
|
+
* command: ["*--force*", "!*--dry-run*"]
|
|
43
|
+
* flags.mode: ["production"]
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* Each key maps to a string-or-list of value glob patterns.
|
|
47
|
+
* A single `!`-prefixed pattern sets `negated: true`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseParamsDimension(raw: unknown, at: string): ParamsDimension;
|
|
50
|
+
/**
|
|
51
|
+
* Parsed `absent` dimension: parameter keys that must NOT be present
|
|
52
|
+
* in the tool call arguments. AND semantics — all listed keys must be absent.
|
|
53
|
+
*/
|
|
54
|
+
export type AbsentDimension = readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* Parse the `absent` field: a list of parameter key names that must be absent.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseAbsentDimension(raw: unknown, at: string): AbsentDimension;
|
|
59
|
+
/**
|
|
60
|
+
* Parsed `agents` dimension: identity candidates the rule applies to.
|
|
61
|
+
* Supported values: `main`, `subagent`, `preset:<name>`.
|
|
62
|
+
* Empty = this rule does not constrain on agent identity.
|
|
63
|
+
*/
|
|
64
|
+
export type AgentsDimension = readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* Parse the `agents` field: a list of agent identity patterns.
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseAgentsDimension(raw: unknown, at: string): AgentsDimension;
|
|
69
|
+
/**
|
|
70
|
+
* Parsed `when` dimension: environment and platform conditions.
|
|
71
|
+
* All conditions are AND — every listed condition must be satisfied.
|
|
72
|
+
*/
|
|
73
|
+
export interface WhenDimension {
|
|
74
|
+
/** Required environment variable values. Key = env var name, value = allowed values (OR). */
|
|
75
|
+
readonly env?: Readonly<Record<string, readonly string[]>>;
|
|
76
|
+
/** Required platform(s). Values are Node.js `process.platform` strings. */
|
|
77
|
+
readonly platform?: readonly string[];
|
|
78
|
+
/** Required Node.js version range (semver-like). Reserved for future use. */
|
|
79
|
+
readonly nodeVersion?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Parse the `when` field: environment and platform conditions.
|
|
83
|
+
*
|
|
84
|
+
* Format:
|
|
85
|
+
* ```yaml
|
|
86
|
+
* when:
|
|
87
|
+
* env:
|
|
88
|
+
* NODE_ENV: ["production"]
|
|
89
|
+
* CI: ["true", "1"]
|
|
90
|
+
* platform: [linux, win32]
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseWhenDimension(raw: unknown, at: string): WhenDimension | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Parsed `argv` dimension: extra argv patterns not covered by command/args.
|
|
96
|
+
* Currently supports `pipeline` patterns (pipe chains like `curl|sh`).
|
|
97
|
+
*/
|
|
98
|
+
export interface ArgvDimension {
|
|
99
|
+
/** Pipeline patterns: match the full command pipeline string. */
|
|
100
|
+
readonly pipeline?: readonly string[];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Parse the `argv` field: extra argv patterns.
|
|
104
|
+
*
|
|
105
|
+
* Format:
|
|
106
|
+
* ```yaml
|
|
107
|
+
* argv:
|
|
108
|
+
* pipeline: ["curl|sh", "wget|bash"]
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export declare function parseArgvDimension(raw: unknown, at: string): ArgvDimension | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Parsed `network` dimension: domain / IP / port / scheme matching.
|
|
114
|
+
* All sub-dimensions are AND — every listed sub-dimension must match.
|
|
115
|
+
* Within a sub-dimension, entries are OR.
|
|
116
|
+
*/
|
|
117
|
+
export interface NetworkDimension {
|
|
118
|
+
/** Domain patterns (glob-capable, e.g. `*.internal.corp`). */
|
|
119
|
+
readonly domains?: readonly string[];
|
|
120
|
+
/** IP/CIDR patterns (e.g. `10.0.0.0/8`, `192.168.1.1`). */
|
|
121
|
+
readonly ips?: readonly string[];
|
|
122
|
+
/** Port patterns (e.g. `443`, `8000-9000`). */
|
|
123
|
+
readonly ports?: readonly string[];
|
|
124
|
+
/** URL schemes (e.g. `https`, `http`). */
|
|
125
|
+
readonly schemes?: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parse the `network` field: domain / IP / port / scheme matching.
|
|
129
|
+
*
|
|
130
|
+
* Format:
|
|
131
|
+
* ```yaml
|
|
132
|
+
* network:
|
|
133
|
+
* domains: ["*.internal.corp", "github.com"]
|
|
134
|
+
* ips: ["10.0.0.0/8"]
|
|
135
|
+
* ports: ["443", "8000-9000"]
|
|
136
|
+
* schemes: [https]
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export declare function parseNetworkDimension(raw: unknown, at: string): NetworkDimension | undefined;
|
package/lib/rule-dims.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended rule dimensions for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* These 6 dimensions extend the existing 4 (tools/command/args/paths) and are
|
|
5
|
+
* parsed from each rule entry in the YAML permissions document. All types are
|
|
6
|
+
* pure — no I/O — so parsing and validation are unit-testable.
|
|
7
|
+
*
|
|
8
|
+
* Dimensions:
|
|
9
|
+
* 1. params — key→value glob matching (AND over keys, `!` prefix negates)
|
|
10
|
+
* 2. absent — parameter keys that must NOT be present
|
|
11
|
+
* 3. agents — agent identity candidates (main/subagent/preset:<name>)
|
|
12
|
+
* 4. when — environment / platform conditions
|
|
13
|
+
* 5. argv — extra argv patterns (pipeline, etc.) not covered by command/args
|
|
14
|
+
* 6. network — domain / IP / port / scheme matching
|
|
15
|
+
*/
|
|
16
|
+
import { RuleError } from './rule.js';
|
|
17
|
+
/**
|
|
18
|
+
* Parse the `params` field from a rule entry.
|
|
19
|
+
*
|
|
20
|
+
* Format:
|
|
21
|
+
* ```yaml
|
|
22
|
+
* params:
|
|
23
|
+
* command: ["*--force*", "!*--dry-run*"]
|
|
24
|
+
* flags.mode: ["production"]
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Each key maps to a string-or-list of value glob patterns.
|
|
28
|
+
* A single `!`-prefixed pattern sets `negated: true`.
|
|
29
|
+
*/
|
|
30
|
+
export function parseParamsDimension(raw, at) {
|
|
31
|
+
if (raw === undefined || raw === null)
|
|
32
|
+
return [];
|
|
33
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
34
|
+
throw new RuleError(`${at}.params must be a mapping`);
|
|
35
|
+
}
|
|
36
|
+
const result = [];
|
|
37
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
38
|
+
if (key.length === 0)
|
|
39
|
+
throw new RuleError(`${at}.params has empty key`);
|
|
40
|
+
const patterns = toStringList(val, `${at}.params.${key}`);
|
|
41
|
+
const negated = patterns.length === 1 && patterns[0].startsWith('!');
|
|
42
|
+
result.push({ key, patterns, negated });
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse the `absent` field: a list of parameter key names that must be absent.
|
|
48
|
+
*/
|
|
49
|
+
export function parseAbsentDimension(raw, at) {
|
|
50
|
+
if (raw === undefined || raw === null)
|
|
51
|
+
return [];
|
|
52
|
+
const list = toStringList(raw, `${at}.absent`);
|
|
53
|
+
for (let i = 0; i < list.length; i++) {
|
|
54
|
+
if (list[i].length === 0)
|
|
55
|
+
throw new RuleError(`${at}.absent[${i}] must be a non-empty string`);
|
|
56
|
+
}
|
|
57
|
+
return list;
|
|
58
|
+
}
|
|
59
|
+
const VALID_AGENT_PATTERNS = /^(?:main|subagent|preset:.+)$/i;
|
|
60
|
+
/**
|
|
61
|
+
* Parse the `agents` field: a list of agent identity patterns.
|
|
62
|
+
*/
|
|
63
|
+
export function parseAgentsDimension(raw, at) {
|
|
64
|
+
if (raw === undefined || raw === null)
|
|
65
|
+
return [];
|
|
66
|
+
const list = toStringList(raw, `${at}.agents`);
|
|
67
|
+
for (let i = 0; i < list.length; i++) {
|
|
68
|
+
const v = list[i];
|
|
69
|
+
if (v.length === 0)
|
|
70
|
+
throw new RuleError(`${at}.agents[${i}] must be a non-empty string`);
|
|
71
|
+
if (!VALID_AGENT_PATTERNS.test(v)) {
|
|
72
|
+
throw new RuleError(`${at}.agents[${i}] must be "main", "subagent", or "preset:<name>"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return list;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse the `when` field: environment and platform conditions.
|
|
79
|
+
*
|
|
80
|
+
* Format:
|
|
81
|
+
* ```yaml
|
|
82
|
+
* when:
|
|
83
|
+
* env:
|
|
84
|
+
* NODE_ENV: ["production"]
|
|
85
|
+
* CI: ["true", "1"]
|
|
86
|
+
* platform: [linux, win32]
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function parseWhenDimension(raw, at) {
|
|
90
|
+
if (raw === undefined || raw === null)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
93
|
+
throw new RuleError(`${at}.when must be a mapping`);
|
|
94
|
+
}
|
|
95
|
+
const obj = raw;
|
|
96
|
+
const env_ = (() => {
|
|
97
|
+
if (obj.env === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof obj.env !== 'object' || Array.isArray(obj.env)) {
|
|
100
|
+
throw new RuleError(`${at}.when.env must be a mapping`);
|
|
101
|
+
}
|
|
102
|
+
const e = {};
|
|
103
|
+
for (const [k, v] of Object.entries(obj.env)) {
|
|
104
|
+
e[k] = toStringList(v, `${at}.when.env.${k}`);
|
|
105
|
+
}
|
|
106
|
+
return e;
|
|
107
|
+
})();
|
|
108
|
+
const platform_ = obj.platform !== undefined ? toStringList(obj.platform, `${at}.when.platform`) : undefined;
|
|
109
|
+
const nodeVersion_ = obj.nodeVersion !== undefined
|
|
110
|
+
? (typeof obj.nodeVersion === 'string' && obj.nodeVersion.length > 0
|
|
111
|
+
? obj.nodeVersion
|
|
112
|
+
: (() => { throw new RuleError(`${at}.when.nodeVersion must be a non-empty string`); })())
|
|
113
|
+
: undefined;
|
|
114
|
+
if (env_ === undefined && platform_ === undefined && nodeVersion_ === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
const result = {
|
|
117
|
+
...(env_ !== undefined && { env: env_ }),
|
|
118
|
+
...(platform_ !== undefined && { platform: platform_ }),
|
|
119
|
+
...(nodeVersion_ !== undefined && { nodeVersion: nodeVersion_ }),
|
|
120
|
+
};
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse the `argv` field: extra argv patterns.
|
|
125
|
+
*
|
|
126
|
+
* Format:
|
|
127
|
+
* ```yaml
|
|
128
|
+
* argv:
|
|
129
|
+
* pipeline: ["curl|sh", "wget|bash"]
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function parseArgvDimension(raw, at) {
|
|
133
|
+
if (raw === undefined || raw === null)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
136
|
+
throw new RuleError(`${at}.argv must be a mapping`);
|
|
137
|
+
}
|
|
138
|
+
const obj = raw;
|
|
139
|
+
const pipeline_ = obj.pipeline !== undefined ? toStringList(obj.pipeline, `${at}.argv.pipeline`) : undefined;
|
|
140
|
+
if (pipeline_ === undefined)
|
|
141
|
+
return undefined;
|
|
142
|
+
return { pipeline: pipeline_ };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse the `network` field: domain / IP / port / scheme matching.
|
|
146
|
+
*
|
|
147
|
+
* Format:
|
|
148
|
+
* ```yaml
|
|
149
|
+
* network:
|
|
150
|
+
* domains: ["*.internal.corp", "github.com"]
|
|
151
|
+
* ips: ["10.0.0.0/8"]
|
|
152
|
+
* ports: ["443", "8000-9000"]
|
|
153
|
+
* schemes: [https]
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function parseNetworkDimension(raw, at) {
|
|
157
|
+
if (raw === undefined || raw === null)
|
|
158
|
+
return undefined;
|
|
159
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
160
|
+
throw new RuleError(`${at}.network must be a mapping`);
|
|
161
|
+
}
|
|
162
|
+
const obj = raw;
|
|
163
|
+
const domains_ = obj.domains !== undefined ? toStringList(obj.domains, `${at}.network.domains`) : undefined;
|
|
164
|
+
const ips_ = obj.ips !== undefined ? toStringList(obj.ips, `${at}.network.ips`) : undefined;
|
|
165
|
+
const ports_ = obj.ports !== undefined ? toStringList(obj.ports, `${at}.network.ports`) : undefined;
|
|
166
|
+
const schemes_ = obj.schemes !== undefined ? toStringList(obj.schemes, `${at}.network.schemes`) : undefined;
|
|
167
|
+
if (domains_ === undefined && ips_ === undefined && ports_ === undefined && schemes_ === undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
const result = {
|
|
170
|
+
...(domains_ !== undefined && { domains: domains_ }),
|
|
171
|
+
...(ips_ !== undefined && { ips: ips_ }),
|
|
172
|
+
...(ports_ !== undefined && { ports: ports_ }),
|
|
173
|
+
...(schemes_ !== undefined && { schemes: schemes_ }),
|
|
174
|
+
};
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
178
|
+
/** Convert an unknown value to a string list (string → [string], array → filtered). */
|
|
179
|
+
function toStringList(value, at) {
|
|
180
|
+
if (value === undefined || value === null)
|
|
181
|
+
return [];
|
|
182
|
+
const list = typeof value === 'string' ? [value] : value;
|
|
183
|
+
if (!Array.isArray(list))
|
|
184
|
+
throw new RuleError(`${at} must be a string or list of strings`);
|
|
185
|
+
const result = [];
|
|
186
|
+
for (let i = 0; i < list.length; i++) {
|
|
187
|
+
const item = list[i];
|
|
188
|
+
if (typeof item !== 'string' || item.length === 0) {
|
|
189
|
+
throw new RuleError(`${at}[${i}] must be a non-empty string`);
|
|
190
|
+
}
|
|
191
|
+
result.push(item);
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
package/lib/rule.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CompiledPattern } from './compiler.js';
|
|
2
|
+
import { type ParamsDimension, type AbsentDimension, type AgentsDimension, type WhenDimension, type ArgvDimension, type NetworkDimension } from './rule-dims.js';
|
|
2
3
|
export type RuleAction = 'allow' | 'ask' | 'deny';
|
|
3
4
|
/** A parsed, shape-validated permissions document (patterns not yet compiled). */
|
|
4
5
|
export interface PermissionsDoc {
|
|
@@ -20,6 +21,18 @@ export interface RuleEntryDoc {
|
|
|
20
21
|
readonly args: string[];
|
|
21
22
|
/** Workspace-relative path globs; empty = no constraint. */
|
|
22
23
|
readonly paths: string[];
|
|
24
|
+
/** Parameter key→value matching (AND over keys, `!` prefix negates). */
|
|
25
|
+
readonly params: ParamsDimension;
|
|
26
|
+
/** Parameter keys that must NOT be present. */
|
|
27
|
+
readonly absent: AbsentDimension;
|
|
28
|
+
/** Agent identity candidates (main / subagent / preset:<name>). */
|
|
29
|
+
readonly agents: AgentsDimension;
|
|
30
|
+
/** Environment / platform conditions. */
|
|
31
|
+
readonly when: WhenDimension | undefined;
|
|
32
|
+
/** Extra argv patterns (pipeline etc.). */
|
|
33
|
+
readonly argv: ArgvDimension | undefined;
|
|
34
|
+
/** Network dimension (domain / IP / port / scheme). */
|
|
35
|
+
readonly network: NetworkDimension | undefined;
|
|
23
36
|
readonly action: RuleAction;
|
|
24
37
|
readonly reason: string;
|
|
25
38
|
readonly enabled: boolean;
|
|
@@ -34,6 +47,18 @@ export interface CompiledRuleEntry {
|
|
|
34
47
|
readonly command: readonly CommandSpec[];
|
|
35
48
|
readonly args: readonly CompiledPattern[];
|
|
36
49
|
readonly paths: readonly CompiledPattern[];
|
|
50
|
+
/** Parsed params dimension (raw patterns; compiled on match). */
|
|
51
|
+
readonly params: ParamsDimension;
|
|
52
|
+
/** Parsed absent dimension (raw key names). */
|
|
53
|
+
readonly absent: AbsentDimension;
|
|
54
|
+
/** Parsed agents dimension (raw patterns). */
|
|
55
|
+
readonly agents: AgentsDimension;
|
|
56
|
+
/** Parsed when dimension (raw conditions). */
|
|
57
|
+
readonly when: WhenDimension | undefined;
|
|
58
|
+
/** Parsed argv dimension (raw patterns). */
|
|
59
|
+
readonly argv: ArgvDimension | undefined;
|
|
60
|
+
/** Parsed network dimension (raw patterns). */
|
|
61
|
+
readonly network: NetworkDimension | undefined;
|
|
37
62
|
readonly source: RuleEntryDoc;
|
|
38
63
|
}
|
|
39
64
|
export interface CommandSpec {
|
package/lib/rule.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { parse } from 'yaml';
|
|
12
12
|
import { compileGlob, hashText } from './compiler.js';
|
|
13
|
+
import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, } from './rule-dims.js';
|
|
13
14
|
const VALID_ACTIONS = ['allow', 'ask', 'deny'];
|
|
14
15
|
const PATH_CANDIDATE_KEYS = [
|
|
15
16
|
'path', 'paths', 'file', 'files', 'file_path', 'dir', 'directory', 'directories',
|
|
@@ -82,7 +83,8 @@ export function parsePermissionsDocument(text) {
|
|
|
82
83
|
function parseRuleEntry(raw, action, at) {
|
|
83
84
|
if (!isRecord(raw))
|
|
84
85
|
throw new RuleError(`${at} must be a mapping`);
|
|
85
|
-
const
|
|
86
|
+
const VALID_KEYS = ['tools', 'command', 'args', 'paths', 'params', 'absent', 'agents', 'when', 'argv', 'network', 'action', 'reason', 'enabled'];
|
|
87
|
+
const unknown = Object.keys(raw).filter((k) => !VALID_KEYS.includes(k));
|
|
86
88
|
if (unknown.length > 0) {
|
|
87
89
|
throw new RuleError(`${at} unknown field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
|
|
88
90
|
}
|
|
@@ -98,6 +100,12 @@ function parseRuleEntry(raw, action, at) {
|
|
|
98
100
|
command: stringList(raw.command, `${at}.command`),
|
|
99
101
|
args: stringList(raw.args, `${at}.args`),
|
|
100
102
|
paths: stringList(raw.paths, `${at}.paths`),
|
|
103
|
+
params: parseParamsDimension(raw.params, `${at}.params`),
|
|
104
|
+
absent: parseAbsentDimension(raw.absent, `${at}.absent`),
|
|
105
|
+
agents: parseAgentsDimension(raw.agents, `${at}.agents`),
|
|
106
|
+
when: parseWhenDimension(raw.when, `${at}.when`),
|
|
107
|
+
argv: parseArgvDimension(raw.argv, `${at}.argv`),
|
|
108
|
+
network: parseNetworkDimension(raw.network, `${at}.network`),
|
|
101
109
|
action,
|
|
102
110
|
reason: reason === undefined ? `${action}` : reason,
|
|
103
111
|
enabled: raw.enabled === undefined ? true : typeof raw.enabled === 'boolean' ? raw.enabled : (() => { throw new RuleError(`${at}.enabled must be a boolean`); })(),
|
|
@@ -144,6 +152,12 @@ export function compileDocument(doc, opts = {}) {
|
|
|
144
152
|
command: entry.command.map((c) => compileCommand(c, opts)),
|
|
145
153
|
args: compilePatternList(entry.args, 'args', opts),
|
|
146
154
|
paths: compilePatternList(entry.paths, 'paths', opts),
|
|
155
|
+
params: entry.params,
|
|
156
|
+
absent: entry.absent,
|
|
157
|
+
agents: entry.agents,
|
|
158
|
+
when: entry.when,
|
|
159
|
+
argv: entry.argv,
|
|
160
|
+
network: entry.network,
|
|
147
161
|
source: entry,
|
|
148
162
|
}));
|
|
149
163
|
return {
|