dsh-perm-gate 2.1.0 → 2.6.1
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.ja.md +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +347 -0
- package/INSTALL.ja.md +84 -8
- package/INSTALL.ko.md +80 -9
- package/INSTALL.md +83 -8
- package/INSTALL.zh.md +70 -7
- package/README.ja.md +130 -16
- package/README.ko.md +128 -17
- package/README.md +179 -25
- package/README.zh.md +121 -12
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +37 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- 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/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- 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/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +26 -3
- package/scripts/patch-permission-glyph.mjs +392 -0
|
@@ -0,0 +1,213 @@
|
|
|
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, compileRuleEntry, 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, opts);
|
|
140
|
+
index += deny.length;
|
|
141
|
+
const allow = flattenEntries(allowDocs, 'allow', index, opts);
|
|
142
|
+
index += allow.length;
|
|
143
|
+
const ask = flattenEntries(askDocs, 'ask', index, opts);
|
|
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
|
+
* Every entry goes through `compileRuleEntry` — the exact compiler the
|
|
157
|
+
* single-file path uses. Building entries by hand here previously left
|
|
158
|
+
* `tools`/`command`/`args`/`paths` as empty arrays, and an empty dimension
|
|
159
|
+
* means "no constraint", so EVERY entry matched EVERY call: the first deny
|
|
160
|
+
* entry denied everything in its partition and the first allow entry allowed
|
|
161
|
+
* everything before the `ask` partition was ever consulted.
|
|
162
|
+
*/
|
|
163
|
+
function flattenEntries(docs, action, startIndex, opts) {
|
|
164
|
+
const result = [];
|
|
165
|
+
let idx = startIndex;
|
|
166
|
+
for (const doc of docs) {
|
|
167
|
+
const list = action === 'deny' ? doc.deny : action === 'allow' ? doc.allow : doc.ask;
|
|
168
|
+
for (const entry of list) {
|
|
169
|
+
result.push(compileRuleEntry(entry, action, idx++, opts));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
// ─── Cache management ──────────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Get or create a compiled ruleset from the cache.
|
|
177
|
+
* Returns the cached version if the hash matches; otherwise compiles fresh.
|
|
178
|
+
*/
|
|
179
|
+
export function cachedCompile(text, opts = {}) {
|
|
180
|
+
const hash = hashText(text);
|
|
181
|
+
const cached = compileCache.get(hash);
|
|
182
|
+
if (cached !== undefined)
|
|
183
|
+
return cached;
|
|
184
|
+
const doc = parsePermissionsDocument(text);
|
|
185
|
+
const compiled = compileDocument(doc, opts);
|
|
186
|
+
compileCache.set(hash, compiled);
|
|
187
|
+
return compiled;
|
|
188
|
+
}
|
|
189
|
+
/** Clear the compile cache (used in tests and on reload). */
|
|
190
|
+
export function clearCompileCache() {
|
|
191
|
+
compileCache.clear();
|
|
192
|
+
}
|
|
193
|
+
/** Current compile cache size (diagnostics). */
|
|
194
|
+
export function compileCacheSize() {
|
|
195
|
+
return compileCache.size;
|
|
196
|
+
}
|
|
197
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
198
|
+
function existsSafe(path) {
|
|
199
|
+
try {
|
|
200
|
+
return existsSync(path);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function statSafe(path) {
|
|
207
|
+
try {
|
|
208
|
+
return statSync(path);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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;
|
|
140
|
+
/**
|
|
141
|
+
* Parsed `branch` dimension: git branch / remote / shared-branch matching.
|
|
142
|
+
*
|
|
143
|
+
* Sub-dimensions are AND — every present sub-dimension must match. Within a
|
|
144
|
+
* sub-dimension, entries are OR.
|
|
145
|
+
*
|
|
146
|
+
* The candidates come from `dispatchCommand` (see `src/command-dispatcher.ts`)
|
|
147
|
+
* over each decomposed simple command, so `refspec` forms (`HEAD:main`) are
|
|
148
|
+
* already split and a remote name is never mistaken for a branch name.
|
|
149
|
+
*
|
|
150
|
+
* `shared` is **static**: it reflects the parser's protected-branch rule
|
|
151
|
+
* (`PROTECTED_BRANCHES` in `src/parsers/git.ts`: main / master / production /
|
|
152
|
+
* release / stable). It does **not** run git to discover whether a branch is
|
|
153
|
+
* genuinely shared — that would put a subprocess on the decision path.
|
|
154
|
+
*/
|
|
155
|
+
export interface BranchDimension {
|
|
156
|
+
/** Branch-name globs (e.g. `main`, `release*`). */
|
|
157
|
+
readonly target?: readonly string[];
|
|
158
|
+
/** Remote-name globs (e.g. `origin`, `upstream`). */
|
|
159
|
+
readonly remote?: readonly string[];
|
|
160
|
+
/** Require the command to target a protected branch. */
|
|
161
|
+
readonly shared?: boolean;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Parse the `branch` field: branch / remote / protected-branch matching.
|
|
165
|
+
*
|
|
166
|
+
* Format:
|
|
167
|
+
* ```yaml
|
|
168
|
+
* branch:
|
|
169
|
+
* target: [main, "release*"]
|
|
170
|
+
* remote: [origin]
|
|
171
|
+
* shared: true
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
export declare function parseBranchDimension(raw: unknown, at: string): BranchDimension | undefined;
|
package/lib/rule-dims.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
/**
|
|
178
|
+
* Parse the `branch` field: branch / remote / protected-branch matching.
|
|
179
|
+
*
|
|
180
|
+
* Format:
|
|
181
|
+
* ```yaml
|
|
182
|
+
* branch:
|
|
183
|
+
* target: [main, "release*"]
|
|
184
|
+
* remote: [origin]
|
|
185
|
+
* shared: true
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export function parseBranchDimension(raw, at) {
|
|
189
|
+
if (raw === undefined || raw === null)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
192
|
+
throw new RuleError(`${at}.branch must be a mapping`);
|
|
193
|
+
}
|
|
194
|
+
const obj = raw;
|
|
195
|
+
const unknownKeys = Object.keys(obj).filter((k) => k !== 'target' && k !== 'remote' && k !== 'shared');
|
|
196
|
+
if (unknownKeys.length > 0) {
|
|
197
|
+
throw new RuleError(`${at}.branch unknown field${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => JSON.stringify(k)).join(', ')} (expected target / remote / shared)`);
|
|
198
|
+
}
|
|
199
|
+
const target_ = obj.target !== undefined ? toStringList(obj.target, `${at}.branch.target`) : undefined;
|
|
200
|
+
const remote_ = obj.remote !== undefined ? toStringList(obj.remote, `${at}.branch.remote`) : undefined;
|
|
201
|
+
if (obj.shared !== undefined && typeof obj.shared !== 'boolean') {
|
|
202
|
+
throw new RuleError(`${at}.branch.shared must be a boolean`);
|
|
203
|
+
}
|
|
204
|
+
const shared_ = obj.shared;
|
|
205
|
+
if (target_ === undefined && remote_ === undefined && shared_ === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
return {
|
|
208
|
+
...(target_ !== undefined && { target: target_ }),
|
|
209
|
+
...(remote_ !== undefined && { remote: remote_ }),
|
|
210
|
+
...(shared_ !== undefined && { shared: shared_ }),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
214
|
+
/** Convert an unknown value to a string list (string → [string], array → filtered). */
|
|
215
|
+
function toStringList(value, at) {
|
|
216
|
+
if (value === undefined || value === null)
|
|
217
|
+
return [];
|
|
218
|
+
const list = typeof value === 'string' ? [value] : value;
|
|
219
|
+
if (!Array.isArray(list))
|
|
220
|
+
throw new RuleError(`${at} must be a string or list of strings`);
|
|
221
|
+
const result = [];
|
|
222
|
+
for (let i = 0; i < list.length; i++) {
|
|
223
|
+
const item = list[i];
|
|
224
|
+
if (typeof item !== 'string' || item.length === 0) {
|
|
225
|
+
throw new RuleError(`${at}[${i}] must be a non-empty string`);
|
|
226
|
+
}
|
|
227
|
+
result.push(item);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|