dsh-permission-rules 0.2.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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +201 -0
- package/README.es.md +182 -0
- package/README.hi.md +182 -0
- package/README.md +182 -0
- package/README.pt.md +182 -0
- package/README.zh.md +182 -0
- package/cordis.patch.yml +16 -0
- package/docs/rules-format.en.md +105 -0
- package/docs/rules-format.md +103 -0
- package/docs/rules-format.schema.json +117 -0
- package/lib/index.js +1606 -0
- package/lib/types/config.d.ts +83 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +72 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/events.d.ts +68 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +21 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/glob.d.ts +55 -0
- package/lib/types/glob.d.ts.map +1 -0
- package/lib/types/glob.js +307 -0
- package/lib/types/glob.js.map +1 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +25 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/prose.d.ts +66 -0
- package/lib/types/prose.d.ts.map +1 -0
- package/lib/types/prose.js +143 -0
- package/lib/types/prose.js.map +1 -0
- package/lib/types/rules.d.ts +226 -0
- package/lib/types/rules.d.ts.map +1 -0
- package/lib/types/rules.js +581 -0
- package/lib/types/rules.js.map +1 -0
- package/lib/types/runtime.d.ts +175 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +497 -0
- package/lib/types/runtime.js.map +1 -0
- package/package.json +103 -0
- package/scripts/repair-session-logs.mjs +327 -0
- package/src/config.ts +141 -0
- package/src/events.ts +75 -0
- package/src/glob.ts +303 -0
- package/src/index.ts +27 -0
- package/src/prose.ts +206 -0
- package/src/rules.ts +695 -0
- package/src/runtime.ts +504 -0
package/src/events.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session vocabulary for `dsh-permission-rules`: the
|
|
3
|
+
* `permissionRules/decision` audit event, appended for every hit AND every
|
|
4
|
+
* passthrough. Log-only — it never enters the model transcript; the only
|
|
5
|
+
* model-visible plugin content is the deny/ask reason the tools registry
|
|
6
|
+
* materializes into the denied tool result, which carries the same
|
|
7
|
+
* `callId`, keeping model-visible ⟺ logged reconstructable.
|
|
8
|
+
*
|
|
9
|
+
* `outcome` records the FINAL pre-execute decision: on a deny/ask hit it
|
|
10
|
+
* equals `action` (the plugin short-circuits); on an allow hit or a
|
|
11
|
+
* passthrough it carries the downstream listeners' decision, so the audit
|
|
12
|
+
* never claims a call was allowed when a later listener denied it.
|
|
13
|
+
*
|
|
14
|
+
* The event is appended with the envelope's `ignorable: true` marker (see
|
|
15
|
+
* {@link AuditAppend}), so a harness build whose generated event vocabulary
|
|
16
|
+
* does not include this out-of-repo type still loads the log — it skips the
|
|
17
|
+
* audit record instead of refusing the whole session.
|
|
18
|
+
* @module dsh-permission-rules/events
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CallId } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
|
|
24
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
25
|
+
interface SessionEventMap {
|
|
26
|
+
/**
|
|
27
|
+
* One permission-rules decision for a pending tool call — log-only
|
|
28
|
+
* audit (like `approval/asked`; NOT a surface event). `action` is
|
|
29
|
+
* `'allow' | 'deny' | 'ask' | 'passthrough'`; `ruleIndex` (0-based) and
|
|
30
|
+
* `reason` appear only on hits. `source` is the absolute rule file of
|
|
31
|
+
* the matched rule, or the nearest effective file on a passthrough, or
|
|
32
|
+
* `''` when no rule file was active. `outcome` is the final pre-execute
|
|
33
|
+
* decision the waterfall settled on.
|
|
34
|
+
*/
|
|
35
|
+
'permissionRules/decision': {
|
|
36
|
+
toolName: string
|
|
37
|
+
callId?: CallId
|
|
38
|
+
source: string
|
|
39
|
+
action: 'allow' | 'deny' | 'ask' | 'passthrough'
|
|
40
|
+
outcome?: 'allow' | 'deny' | 'ask'
|
|
41
|
+
ruleIndex?: number
|
|
42
|
+
reason?: string
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every action a decision event can carry. */
|
|
48
|
+
export type DecisionAction = 'allow' | 'deny' | 'ask' | 'passthrough'
|
|
49
|
+
|
|
50
|
+
/** The final pre-execute decision kinds a host can settle on. */
|
|
51
|
+
export type DecisionOutcome = PreToolDecision['kind']
|
|
52
|
+
|
|
53
|
+
/** The payload of one `permissionRules/decision` audit record. */
|
|
54
|
+
export interface AuditDecision {
|
|
55
|
+
toolName: string
|
|
56
|
+
callId?: CallId
|
|
57
|
+
source: string
|
|
58
|
+
action: DecisionAction
|
|
59
|
+
outcome?: DecisionOutcome
|
|
60
|
+
ruleIndex?: number
|
|
61
|
+
reason?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `Session.append` narrowed to this plugin's audit event. The options bag
|
|
66
|
+
* exists only on host builds that expose the `ignorable` envelope-marker
|
|
67
|
+
* surface (post-rc.6 `@deepseek-ai/dsh-session`); an rc.6 host accepts and
|
|
68
|
+
* ignores the third argument, appending the identical event without the
|
|
69
|
+
* marker — no behavior change, no failure either way.
|
|
70
|
+
*/
|
|
71
|
+
export type AuditAppend = (
|
|
72
|
+
type: 'permissionRules/decision',
|
|
73
|
+
data: AuditDecision,
|
|
74
|
+
options?: { ignorable?: true },
|
|
75
|
+
) => unknown
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict glob→RegExp compilation and regex-safety validation for rule
|
|
3
|
+
* patterns. A conservative, validated glob subset: `*` (within one path
|
|
4
|
+
* segment), `**` (any depth, including zero), `?` (one character),
|
|
5
|
+
* `[abc]` / `[!abc]` character classes, and `\x` escapes. Unbalanced
|
|
6
|
+
* brackets, empty classes, and trailing escapes throw at compile time — a
|
|
7
|
+
* bad glob must fail loudly at load, never silently match nothing at
|
|
8
|
+
* runtime.
|
|
9
|
+
*
|
|
10
|
+
* Catastrophic-backtracking defense, tuned per pattern flavor:
|
|
11
|
+
* - Glob output is fully machine-generated, so the backtracking degree is
|
|
12
|
+
* exactly the number of star expansions: each `*`/`**` emits exactly one
|
|
13
|
+
* unbounded quantifier and no group is ever emitted. {@link compileGlob}
|
|
14
|
+
* therefore caps that count via `maxStars`.
|
|
15
|
+
* - User regexes (patternMode `regex`) are scanned structurally: a
|
|
16
|
+
* quantifier applied to a group containing an unbounded quantifier, and a
|
|
17
|
+
* quantified group whose literal alternation branches overlap, are
|
|
18
|
+
* rejected at load. Chains of independent top-level quantifiers
|
|
19
|
+
* (`\d+\.\d+\.\d+`) stay allowed — a documented, deliberate limitation:
|
|
20
|
+
* regex mode is the escape hatch, glob mode is the guarded default.
|
|
21
|
+
* @module dsh-permission-rules/glob
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Raised by {@link compileGlob} on a syntactically invalid or over-quantified pattern. */
|
|
25
|
+
export class GlobError extends TypeError {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'GlobError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Compile options for {@link compileGlob}. */
|
|
33
|
+
export interface GlobCompileOptions {
|
|
34
|
+
/** `true` keeps `*`/`?` inside one path segment (for `paths` patterns). */
|
|
35
|
+
readonly segments: boolean
|
|
36
|
+
/** Compile with the `i` flag so matching ignores ASCII case. */
|
|
37
|
+
readonly caseInsensitive?: boolean
|
|
38
|
+
/** Hard cap on unbounded star expansions; exceeding it throws. Unset = unlimited. */
|
|
39
|
+
readonly maxStars?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compile one glob pattern to an anchored regular expression.
|
|
44
|
+
* @param pattern - the glob source.
|
|
45
|
+
* @param options - segment semantics, case handling, and the star cap.
|
|
46
|
+
* @returns a RegExp matching the whole candidate string.
|
|
47
|
+
* @throws {@link GlobError} on unbalanced `[`, empty classes, a trailing
|
|
48
|
+
* escape, or more star expansions than `options.maxStars` allows.
|
|
49
|
+
*/
|
|
50
|
+
export function compileGlob(pattern: string, options: GlobCompileOptions): RegExp {
|
|
51
|
+
let out = ''
|
|
52
|
+
let stars = 0
|
|
53
|
+
let i = 0
|
|
54
|
+
while (i < pattern.length) {
|
|
55
|
+
const ch = pattern[i] as string
|
|
56
|
+
if (ch === '\\') {
|
|
57
|
+
const next = pattern[i + 1]
|
|
58
|
+
if (next === undefined) throw new GlobError(`glob ${JSON.stringify(pattern)} ends with a dangling escape`)
|
|
59
|
+
out += escapeRegExpChar(next)
|
|
60
|
+
i += 2
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (ch === '*') {
|
|
64
|
+
if (pattern[i + 1] === '*') {
|
|
65
|
+
// `**` crosses path segments. Collapse runs of `**` and any
|
|
66
|
+
// following separator into one zero-or-more-any-depth group, so
|
|
67
|
+
// `a/**/b`, `a/**b`, and `a/***/b` all behave the same.
|
|
68
|
+
out += '.*'
|
|
69
|
+
stars += 1
|
|
70
|
+
i += 2
|
|
71
|
+
while (pattern[i] === '*') i += 1
|
|
72
|
+
if (pattern[i] === '/') i += 1
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
// A single star never crosses a path separator.
|
|
76
|
+
out += options.segments ? '[^/]*' : '.*'
|
|
77
|
+
stars += 1
|
|
78
|
+
i += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (ch === '?') {
|
|
82
|
+
out += options.segments ? '[^/]' : '.'
|
|
83
|
+
i += 1
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (ch === '[') {
|
|
87
|
+
const end = pattern.indexOf(']', i + 1)
|
|
88
|
+
if (end < 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an unbalanced "["`)
|
|
89
|
+
const body = pattern.slice(i + 1, end)
|
|
90
|
+
if (body.length === 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an empty character class`)
|
|
91
|
+
let content = body
|
|
92
|
+
let negate = false
|
|
93
|
+
if (content.startsWith('!')) {
|
|
94
|
+
negate = true
|
|
95
|
+
content = content.slice(1)
|
|
96
|
+
}
|
|
97
|
+
if (content.length === 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an empty character class`)
|
|
98
|
+
out += `[${negate ? '^' : ''}${content.replaceAll('\\', '\\\\')}]`
|
|
99
|
+
i = end + 1
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (ch === ']') throw new GlobError(`glob ${JSON.stringify(pattern)} has an unbalanced "]"`)
|
|
103
|
+
out += escapeRegExpChar(ch)
|
|
104
|
+
i += 1
|
|
105
|
+
}
|
|
106
|
+
if (options.maxStars !== undefined && stars > options.maxStars) {
|
|
107
|
+
throw new GlobError(`glob ${JSON.stringify(pattern)} expands to ${stars} unbounded star quantifiers (max ${options.maxStars}); split it into several patterns`)
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return new RegExp(`^${out}$`, options.caseInsensitive === true ? 'iu' : 'u')
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new GlobError(`glob ${JSON.stringify(pattern)} compiles to an invalid regular expression: ${String(error)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Escape one literal character for a RegExp source. */
|
|
117
|
+
function escapeRegExpChar(ch: string): string {
|
|
118
|
+
return /[.*+?^${}()|[\]\\/]/.test(ch) ? `\\${ch}` : ch
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compile one pattern as an unanchored regex, rejecting syntax errors and
|
|
123
|
+
* catastrophic-backtracking structures loudly.
|
|
124
|
+
* @param pattern - the regex source.
|
|
125
|
+
* @returns the compiled RegExp.
|
|
126
|
+
* @throws TypeError when the pattern is invalid, nests an unbounded
|
|
127
|
+
* quantifier, or quantifies a group with overlapping literal alternation
|
|
128
|
+
* branches.
|
|
129
|
+
*/
|
|
130
|
+
export function compilePatternRegex(pattern: string): RegExp {
|
|
131
|
+
let compiled: RegExp
|
|
132
|
+
try {
|
|
133
|
+
compiled = new RegExp(pattern, 'u')
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new TypeError(`pattern ${JSON.stringify(pattern)} is not a valid regular expression: ${String(error)}`, { cause: error })
|
|
136
|
+
}
|
|
137
|
+
assertSafeRegexStructure(pattern)
|
|
138
|
+
return compiled
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Structural ReDoS scan. Rejects (a) an unbounded quantifier (`*`, `+`,
|
|
143
|
+
* `{m,}`) applied to a group whose body contains an unbounded quantifier,
|
|
144
|
+
* and (b) a quantified group whose fully-literal alternation branches
|
|
145
|
+
* overlap as prefixes. Everything else — including chains of independent
|
|
146
|
+
* top-level quantifiers — compiles untouched.
|
|
147
|
+
* @param pattern - the already-syntax-valid regex source.
|
|
148
|
+
* @throws TypeError on a rejected structure.
|
|
149
|
+
*/
|
|
150
|
+
function assertSafeRegexStructure(pattern: string): void {
|
|
151
|
+
const stack: { start: number; unbounded: boolean }[] = []
|
|
152
|
+
let i = 0
|
|
153
|
+
const markUnbounded = (): void => {
|
|
154
|
+
const top = stack[stack.length - 1]
|
|
155
|
+
if (top !== undefined) top.unbounded = true
|
|
156
|
+
}
|
|
157
|
+
const reject = (message: string): never => {
|
|
158
|
+
throw new TypeError(`pattern ${JSON.stringify(pattern)} is rejected: ${message} risks catastrophic backtracking; restructure the pattern or use glob mode`)
|
|
159
|
+
}
|
|
160
|
+
while (i < pattern.length) {
|
|
161
|
+
const ch = pattern[i] as string
|
|
162
|
+
if (ch === '\\') {
|
|
163
|
+
i += 2
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (ch === '[') {
|
|
167
|
+
i += 1
|
|
168
|
+
while (i < pattern.length && pattern[i] !== ']') {
|
|
169
|
+
if (pattern[i] === '\\') i += 1
|
|
170
|
+
i += 1
|
|
171
|
+
}
|
|
172
|
+
i += 1
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (ch === '(') {
|
|
176
|
+
stack.push({ start: i, unbounded: false })
|
|
177
|
+
i += 1
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (ch === ')') {
|
|
181
|
+
const group = stack.pop()
|
|
182
|
+
// An unbalanced `)` is invalid syntax; the compile above already rejected it.
|
|
183
|
+
if (group === undefined) {
|
|
184
|
+
i += 1
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
const quantifier = quantifierAt(pattern, i + 1)
|
|
188
|
+
if (quantifier !== undefined) {
|
|
189
|
+
if (quantifier.unbounded) {
|
|
190
|
+
if (group.unbounded) {
|
|
191
|
+
reject(`quantifier ${quantifier.text} applies to a group containing an unbounded quantifier`)
|
|
192
|
+
}
|
|
193
|
+
if (hasAmbiguousAlternation(pattern, group.start + 1, i)) {
|
|
194
|
+
reject(`quantified group ${JSON.stringify(pattern.slice(group.start, quantifier.end + 1))} has overlapping alternation branches`)
|
|
195
|
+
}
|
|
196
|
+
markUnbounded()
|
|
197
|
+
}
|
|
198
|
+
i = quantifier.end + 1
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (group.unbounded) markUnbounded()
|
|
202
|
+
i += 1
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (ch === '*' || ch === '+') {
|
|
206
|
+
markUnbounded()
|
|
207
|
+
i += 1
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (ch === '{') {
|
|
211
|
+
const parsed = parseBraceQuantifier(pattern, i)
|
|
212
|
+
if (parsed !== undefined) {
|
|
213
|
+
if (parsed.unbounded) markUnbounded()
|
|
214
|
+
i = parsed.end + 1
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
// Not a quantifier (a `\p{L}`-style body is reached after its escape
|
|
218
|
+
// was skipped above): an ordinary literal.
|
|
219
|
+
i += 1
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
// `?` is always bounded; literals, `.`, `|`, `^`, `$` pass through.
|
|
223
|
+
i += 1
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One parsed quantifier token, when the character at `index` starts one. */
|
|
228
|
+
function quantifierAt(pattern: string, index: number): { text: string; end: number; unbounded: boolean } | undefined {
|
|
229
|
+
const ch = pattern[index]
|
|
230
|
+
if (ch === '*' || ch === '+') return { text: ch, end: index, unbounded: true }
|
|
231
|
+
if (ch === '?') return { text: ch, end: index, unbounded: false }
|
|
232
|
+
if (ch === '{') return parseBraceQuantifier(pattern, index)
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Parse `{m}`, `{m,}`, or `{m,n}` at `start`; other braces are not quantifiers. */
|
|
237
|
+
function parseBraceQuantifier(pattern: string, start: number): { text: string; end: number; unbounded: boolean } | undefined {
|
|
238
|
+
const close = pattern.indexOf('}', start + 1)
|
|
239
|
+
if (close < 0) return undefined
|
|
240
|
+
const body = pattern.slice(start + 1, close)
|
|
241
|
+
if (!/^\d+(,\d*)?$/.test(body)) return undefined
|
|
242
|
+
const unbounded = body.includes(',') && body.endsWith(',')
|
|
243
|
+
return { text: pattern.slice(start, close + 1), end: close, unbounded }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether a group body (between the parens) contains a top-level
|
|
248
|
+
* alternation with two fully-literal branches where one is a prefix of the
|
|
249
|
+
* other — the `(a|aa)+` ambiguity that makes repetition exponential.
|
|
250
|
+
* @param pattern - the full pattern.
|
|
251
|
+
* @param start - index just after the group's `(`.
|
|
252
|
+
* @param end - index of the group's `)`.
|
|
253
|
+
* @returns true when such an overlap exists.
|
|
254
|
+
*/
|
|
255
|
+
function hasAmbiguousAlternation(pattern: string, start: number, end: number): boolean {
|
|
256
|
+
const branches: string[] = []
|
|
257
|
+
let depth = 0
|
|
258
|
+
let branchStart = start
|
|
259
|
+
let hasPipe = false
|
|
260
|
+
for (let i = start; i < end; i += 1) {
|
|
261
|
+
const ch = pattern[i] as string
|
|
262
|
+
if (ch === '\\') {
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (ch === '[') {
|
|
267
|
+
while (i < end && pattern[i] !== ']') {
|
|
268
|
+
if (pattern[i] === '\\') i += 1
|
|
269
|
+
i += 1
|
|
270
|
+
}
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (ch === '(') {
|
|
274
|
+
depth += 1
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (ch === ')') {
|
|
278
|
+
depth -= 1
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (ch === '|' && depth === 0) {
|
|
282
|
+
hasPipe = true
|
|
283
|
+
branches.push(pattern.slice(branchStart, i))
|
|
284
|
+
branchStart = i + 1
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!hasPipe) return false
|
|
288
|
+
branches.push(pattern.slice(branchStart, end))
|
|
289
|
+
for (let a = 0; a < branches.length; a += 1) {
|
|
290
|
+
for (let b = a + 1; b < branches.length; b += 1) {
|
|
291
|
+
if (literalPrefixOf(branches[a] as string, branches[b] as string)) return true
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** True when two fully-literal branches share their complete shorter text as a prefix. */
|
|
298
|
+
function literalPrefixOf(x: string, y: string): boolean {
|
|
299
|
+
const literal = /^[^*+?()|[\]{}^$.\\]+$/
|
|
300
|
+
if (!literal.test(x) || !literal.test(y)) return false
|
|
301
|
+
const min = Math.min(x.length, y.length)
|
|
302
|
+
return x.slice(0, min) === y.slice(0, min)
|
|
303
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-permission-rules` — Claude Code-style declarative permission rules
|
|
3
|
+
* for DeepSeek Harness. A `tools/pre-execute` waterfall listener evaluates
|
|
4
|
+
* an ordered `allow`/`deny`/`ask` rule list (tool-name globs, argument
|
|
5
|
+
* glob/regex matching, workspace-path matching) per session cwd; deny and
|
|
6
|
+
* ask decisions short-circuit the chain, while allow and passthrough always
|
|
7
|
+
* delegate via `next()`. `ask` rides the official approval seam, where
|
|
8
|
+
* answerers such as `dsh-auto-review` or a human UI decide — the plugin
|
|
9
|
+
* itself never runs a reviewer. Every decision is audit-logged.
|
|
10
|
+
*
|
|
11
|
+
* Function plugin — no default export (the Loader unwraps
|
|
12
|
+
* `exports.default ?? exports`, and a stray default would discard
|
|
13
|
+
* `name`/`inject`/`Config`/`apply`).
|
|
14
|
+
* @module dsh-permission-rules
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { apply } from './runtime.ts'
|
|
18
|
+
|
|
19
|
+
export const name = 'permission-rules'
|
|
20
|
+
export const inject = ['commands', 'tools']
|
|
21
|
+
|
|
22
|
+
export { apply }
|
|
23
|
+
export { PermissionRulesRuntime } from './runtime.ts'
|
|
24
|
+
export * from './config.ts'
|
|
25
|
+
export * from './events.ts'
|
|
26
|
+
export * from './rules.ts'
|
|
27
|
+
export * from './glob.ts'
|
package/src/prose.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI-facing prose for the `/rules` session command, per language. Only the
|
|
3
|
+
* command surface localizes: rule `reason`s are author-provided and are
|
|
4
|
+
* never translated, and logger diagnostics stay English for greppability.
|
|
5
|
+
* `en` and `zh` are the reference translations; `es`/`pt`/`hi` are
|
|
6
|
+
* community-quality.
|
|
7
|
+
* @module dsh-permission-rules/prose
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Supported command-output languages. */
|
|
11
|
+
export type UiLanguage = 'en' | 'zh' | 'es' | 'pt' | 'hi'
|
|
12
|
+
|
|
13
|
+
/** The localized strings one language provides for the command surface. */
|
|
14
|
+
export interface UiProse {
|
|
15
|
+
/** Header line naming the active rules, their sources, and the workspace. */
|
|
16
|
+
rulesHeader: (count: number, sources: readonly string[], cwd: string) => string
|
|
17
|
+
/** Line shown when no rule file is in effect. */
|
|
18
|
+
noRules: (cwd: string, fallbackNote: string) => string
|
|
19
|
+
/** Note appended when a fallback path is configured but missing. */
|
|
20
|
+
fallbackMissing: string
|
|
21
|
+
/** Success line of `/rules reload`. */
|
|
22
|
+
reloaded: (count: number, source: string) => string
|
|
23
|
+
/** Error line of a failed reload. */
|
|
24
|
+
reloadFailed: (error: string) => string
|
|
25
|
+
/** Warning appended to a listing whose last reload failed. */
|
|
26
|
+
lastReloadWarning: (error: string) => string
|
|
27
|
+
/** Rejection of an unknown `/rules` argument. */
|
|
28
|
+
unknownArg: (arg: string) => string
|
|
29
|
+
/** Usage hint. */
|
|
30
|
+
usage: string
|
|
31
|
+
/** Header for `/rules decisions` when decisions exist. */
|
|
32
|
+
decisionsHeader: (shown: number, total: number) => string
|
|
33
|
+
/** Line shown when the session log holds no audit decisions. */
|
|
34
|
+
noDecisions: string
|
|
35
|
+
/** Rejection of a malformed decisions count. */
|
|
36
|
+
invalidDecisionsCount: (arg: string) => string
|
|
37
|
+
/** One audit-decision row. */
|
|
38
|
+
decisionLine: (seq: number, action: string, toolName: string, ruleIndex: number | undefined, reason: string | undefined) => string
|
|
39
|
+
/** Hit line of `/rules test`. */
|
|
40
|
+
testHit: (tool: string, ruleIndex: number, action: string, reason: string) => string
|
|
41
|
+
/** No-match line of `/rules test`. */
|
|
42
|
+
testNoMatch: (tool: string) => string
|
|
43
|
+
/** Rejection of unparseable `/rules test` JSON arguments. */
|
|
44
|
+
testBadJson: (text: string) => string
|
|
45
|
+
/** Usage hint of `/rules test`. */
|
|
46
|
+
testUsage: string
|
|
47
|
+
/** Warning naming shadowed (unreachable) rules by 1-based number. */
|
|
48
|
+
unreachableWarning: (numbers: readonly number[]) => string
|
|
49
|
+
/** Empty-source placeholder in the rules header. */
|
|
50
|
+
emptySource: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tokens `describeRule` uses for its match-dimension prefixes. */
|
|
54
|
+
export interface DescribeTokens {
|
|
55
|
+
allTools: string
|
|
56
|
+
tools: string
|
|
57
|
+
params: string
|
|
58
|
+
paths: string
|
|
59
|
+
absent: string
|
|
60
|
+
when: string
|
|
61
|
+
platform: string
|
|
62
|
+
disabled: string
|
|
63
|
+
tags: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Truncate long reasons/descriptions in one-line displays. */
|
|
67
|
+
function short(text: string, limit = 120): string {
|
|
68
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EN: UiProse = {
|
|
72
|
+
rulesHeader: (count, sources, cwd) => `Permission rules: ${count} rule(s) from ${sources.join(', ')} (workspace ${cwd}).`,
|
|
73
|
+
noRules: (cwd, fallbackNote) => `No permission rules active: no rule file found for workspace ${cwd}${fallbackNote}; the empty rule set passes everything through.`,
|
|
74
|
+
fallbackMissing: ' (and the configured fallback path is missing)',
|
|
75
|
+
reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
|
|
76
|
+
reloadFailed: error => `Reload failed: ${error}. The previous rules are still active.`,
|
|
77
|
+
lastReloadWarning: error => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
|
|
78
|
+
unknownArg: arg => `Unknown /rules argument "${arg}". ${EN.usage}`,
|
|
79
|
+
usage: 'Usage: /rules [reload | decisions [n] | test <tool> <json-args>]',
|
|
80
|
+
decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
|
|
81
|
+
noDecisions: 'No permission decisions recorded in this session yet.',
|
|
82
|
+
invalidDecisionsCount: arg => `Invalid decisions count "${arg}": give a positive integer (default 10).`,
|
|
83
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
84
|
+
const rule = ruleIndex === undefined ? '' : ` (rule ${ruleIndex + 1})`
|
|
85
|
+
const why = reason === undefined ? '' : `: ${short(reason)}`
|
|
86
|
+
return `- #${seq} ${action} ${toolName}${rule}${why}`
|
|
87
|
+
},
|
|
88
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
89
|
+
testNoMatch: tool => `"${tool}" matches no rule — the call passes through.`,
|
|
90
|
+
testBadJson: text => `Invalid JSON arguments "${text}": the arguments must parse as JSON.`,
|
|
91
|
+
testUsage: 'Usage: /rules test <tool> <json-args>, e.g. /rules test bash {"command":"git push origin main"}',
|
|
92
|
+
unreachableWarning: numbers => `Warning: rule${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')} ${numbers.length > 1 ? 'are' : 'is'} unreachable (shadowed by an earlier catch-all rule).`,
|
|
93
|
+
emptySource: '(no rule file — empty rule set)',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ZH: UiProse = {
|
|
97
|
+
rulesHeader: (count, sources, cwd) => `权限规则:共 ${count} 条,来自 ${sources.join('、')}(工作区 ${cwd})。`,
|
|
98
|
+
noRules: (cwd, fallbackNote) => `当前没有生效的权限规则:工作区 ${cwd} 未找到规则文件${fallbackNote};空规则集全部放行。`,
|
|
99
|
+
fallbackMissing: '(且配置的回退路径不存在)',
|
|
100
|
+
reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
|
|
101
|
+
reloadFailed: error => `重载失败:${error}。之前的规则仍然生效。`,
|
|
102
|
+
lastReloadWarning: error => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
|
|
103
|
+
unknownArg: arg => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
|
|
104
|
+
usage: '用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]',
|
|
105
|
+
decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
|
|
106
|
+
noDecisions: '本会话还没有记录任何权限裁决。',
|
|
107
|
+
invalidDecisionsCount: arg => `无效的裁决条数 "${arg}":请给正整数(默认 10)。`,
|
|
108
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
109
|
+
const rule = ruleIndex === undefined ? '' : `(规则 ${ruleIndex + 1})`
|
|
110
|
+
const why = reason === undefined ? '' : `:${short(reason)}`
|
|
111
|
+
return `- #${seq} ${action} ${toolName}${rule}${why}`
|
|
112
|
+
},
|
|
113
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
|
|
114
|
+
testNoMatch: tool => `"${tool}" 未命中任何规则——该调用将透传。`,
|
|
115
|
+
testBadJson: text => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
|
|
116
|
+
testUsage: '用法:/rules test <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
|
|
117
|
+
unreachableWarning: numbers => `警告:规则 ${numbers.join('、')} 不可达(被前面的通配规则遮蔽)。`,
|
|
118
|
+
emptySource: '(无规则文件——空规则集)',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ES: UiProse = {
|
|
122
|
+
rulesHeader: (count, sources, cwd) => `Reglas de permiso: ${count} regla(s) desde ${sources.join(', ')} (espacio de trabajo ${cwd}).`,
|
|
123
|
+
noRules: (cwd, fallbackNote) => `No hay reglas de permiso activas: no se encontró archivo de reglas para el espacio de trabajo ${cwd}${fallbackNote}; el conjunto vacío lo permite todo.`,
|
|
124
|
+
fallbackMissing: ' (y la ruta de respaldo configurada no existe)',
|
|
125
|
+
reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
|
|
126
|
+
reloadFailed: error => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
|
|
127
|
+
lastReloadWarning: error => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
|
|
128
|
+
unknownArg: arg => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
|
|
129
|
+
usage: 'Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]',
|
|
130
|
+
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
|
|
131
|
+
noDecisions: 'Esta sesión aún no registra decisiones de permiso.',
|
|
132
|
+
invalidDecisionsCount: arg => `Cantidad de decisiones inválida "${arg}": indique un entero positivo (por defecto 10).`,
|
|
133
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
134
|
+
const rule = ruleIndex === undefined ? '' : ` (regla ${ruleIndex + 1})`
|
|
135
|
+
const why = reason === undefined ? '' : `: ${short(reason)}`
|
|
136
|
+
return `- #${seq} ${action} ${toolName}${rule}${why}`
|
|
137
|
+
},
|
|
138
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
139
|
+
testNoMatch: tool => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
|
|
140
|
+
testBadJson: text => `Argumentos JSON inválidos "${text}": los argumentos deben poderse analizar como JSON.`,
|
|
141
|
+
testUsage: 'Uso: /rules test <herramienta> <json-args>, p. ej. /rules test bash {"command":"git push origin main"}',
|
|
142
|
+
unreachableWarning: numbers => `Aviso: ${numbers.length > 1 ? 'las reglas' : 'la regla'} ${numbers.join(', ')} ${numbers.length > 1 ? 'son inalcanzables' : 'es inalcanzable'} (tapada por una regla general anterior).`,
|
|
143
|
+
emptySource: '(sin archivo de reglas — conjunto vacío)',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const PT: UiProse = {
|
|
147
|
+
rulesHeader: (count, sources, cwd) => `Regras de permissão: ${count} regra(s) de ${sources.join(', ')} (workspace ${cwd}).`,
|
|
148
|
+
noRules: (cwd, fallbackNote) => `Nenhuma regra de permissão ativa: nenhum arquivo de regras encontrado para o workspace ${cwd}${fallbackNote}; o conjunto vazio permite tudo.`,
|
|
149
|
+
fallbackMissing: ' (e o caminho de fallback configurado não existe)',
|
|
150
|
+
reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
|
|
151
|
+
reloadFailed: error => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
|
|
152
|
+
lastReloadWarning: error => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
|
|
153
|
+
unknownArg: arg => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
|
|
154
|
+
usage: 'Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]',
|
|
155
|
+
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
|
|
156
|
+
noDecisions: 'Esta sessão ainda não registrou decisões de permissão.',
|
|
157
|
+
invalidDecisionsCount: arg => `Quantidade de decisões inválida "${arg}": informe um inteiro positivo (padrão 10).`,
|
|
158
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
159
|
+
const rule = ruleIndex === undefined ? '' : ` (regra ${ruleIndex + 1})`
|
|
160
|
+
const why = reason === undefined ? '' : `: ${short(reason)}`
|
|
161
|
+
return `- #${seq} ${action} ${toolName}${rule}${why}`
|
|
162
|
+
},
|
|
163
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
164
|
+
testNoMatch: tool => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
|
|
165
|
+
testBadJson: text => `Argumentos JSON inválidos "${text}": os argumentos precisam ser JSON analisável.`,
|
|
166
|
+
testUsage: 'Uso: /rules test <ferramenta> <json-args>, ex.: /rules test bash {"command":"git push origin main"}',
|
|
167
|
+
unreachableWarning: numbers => `Aviso: ${numbers.length > 1 ? 'as regras' : 'a regra'} ${numbers.join(', ')} ${numbers.length > 1 ? 'são inalcançáveis' : 'é inalcançável'} (encoberta por uma regra geral anterior).`,
|
|
168
|
+
emptySource: '(sem arquivo de regras — conjunto vazio)',
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const HI: UiProse = {
|
|
172
|
+
rulesHeader: (count, sources, cwd) => `अनुमति नियम: ${count} नियम, स्रोत ${sources.join(', ')} (कार्यक्षेत्र ${cwd})।`,
|
|
173
|
+
noRules: (cwd, fallbackNote) => `कोई अनुमति नियम सक्रिय नहीं: कार्यक्षेत्र ${cwd} के लिए नियम फ़ाइल नहीं मिली${fallbackNote}; खाली नियम-समूह सब कुछ पास कर देता है।`,
|
|
174
|
+
fallbackMissing: ' (और कॉन्फ़िगर किया गया फ़ॉलबैक पथ मौजूद नहीं है)',
|
|
175
|
+
reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
|
|
176
|
+
reloadFailed: error => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
|
|
177
|
+
lastReloadWarning: error => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
|
|
178
|
+
unknownArg: arg => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
|
|
179
|
+
usage: 'उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]',
|
|
180
|
+
decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
|
|
181
|
+
noDecisions: 'इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।',
|
|
182
|
+
invalidDecisionsCount: arg => `अमान्य निर्णय-संख्या "${arg}": एक धनात्मक पूर्णांक दें (डिफ़ॉल्ट 10)।`,
|
|
183
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
184
|
+
const rule = ruleIndex === undefined ? '' : ` (नियम ${ruleIndex + 1})`
|
|
185
|
+
const why = reason === undefined ? '' : `: ${short(reason)}`
|
|
186
|
+
return `- #${seq} ${action} ${toolName}${rule}${why}`
|
|
187
|
+
},
|
|
188
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
|
|
189
|
+
testNoMatch: tool => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
|
|
190
|
+
testBadJson: text => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
|
|
191
|
+
testUsage: 'उपयोग: /rules test <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
|
|
192
|
+
unreachableWarning: numbers => `चेतावनी: नियम ${numbers.join(', ')} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
|
|
193
|
+
emptySource: '(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)',
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The localized prose tables by language. */
|
|
197
|
+
export const UI_PROSE: Readonly<Record<UiLanguage, UiProse>> = { en: EN, zh: ZH, es: ES, pt: PT, hi: HI }
|
|
198
|
+
|
|
199
|
+
/** `describeRule` match-dimension tokens by language. */
|
|
200
|
+
export const DESCRIBE_TOKENS: Readonly<Record<UiLanguage, DescribeTokens>> = {
|
|
201
|
+
en: { allTools: 'all tools', tools: 'tools', params: 'params', paths: 'paths', absent: 'absent', when: 'when', platform: 'platform', disabled: 'disabled', tags: 'tags' },
|
|
202
|
+
zh: { allTools: '全部工具', tools: '工具', params: '参数', paths: '路径', absent: '缺省键', when: '条件', platform: '平台', disabled: '已禁用', tags: '标签' },
|
|
203
|
+
es: { allTools: 'todas las herramientas', tools: 'herramientas', params: 'parámetros', paths: 'rutas', absent: 'ausentes', when: 'cuándo', platform: 'plataforma', disabled: 'deshabilitada', tags: 'etiquetas' },
|
|
204
|
+
pt: { allTools: 'todas as ferramentas', tools: 'ferramentas', params: 'parâmetros', paths: 'caminhos', absent: 'ausentes', when: 'quando', platform: 'plataforma', disabled: 'desativada', tags: 'etiquetas' },
|
|
205
|
+
hi: { allTools: 'सभी टूल', tools: 'टूल', params: 'पैरामीटर', paths: 'पथ', absent: 'अनुपस्थित', when: 'शर्त', platform: 'प्लेटफ़ॉर्म', disabled: 'अक्षम', tags: 'टैग' },
|
|
206
|
+
}
|