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.
Files changed (49) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +182 -0
  4. package/README.hi.md +182 -0
  5. package/README.md +182 -0
  6. package/README.pt.md +182 -0
  7. package/README.zh.md +182 -0
  8. package/cordis.patch.yml +16 -0
  9. package/docs/rules-format.en.md +105 -0
  10. package/docs/rules-format.md +103 -0
  11. package/docs/rules-format.schema.json +117 -0
  12. package/lib/index.js +1606 -0
  13. package/lib/types/config.d.ts +83 -0
  14. package/lib/types/config.d.ts.map +1 -0
  15. package/lib/types/config.js +72 -0
  16. package/lib/types/config.js.map +1 -0
  17. package/lib/types/events.d.ts +68 -0
  18. package/lib/types/events.d.ts.map +1 -0
  19. package/lib/types/events.js +21 -0
  20. package/lib/types/events.js.map +1 -0
  21. package/lib/types/glob.d.ts +55 -0
  22. package/lib/types/glob.d.ts.map +1 -0
  23. package/lib/types/glob.js +307 -0
  24. package/lib/types/glob.js.map +1 -0
  25. package/lib/types/index.d.ts +25 -0
  26. package/lib/types/index.d.ts.map +1 -0
  27. package/lib/types/index.js +25 -0
  28. package/lib/types/index.js.map +1 -0
  29. package/lib/types/prose.d.ts +66 -0
  30. package/lib/types/prose.d.ts.map +1 -0
  31. package/lib/types/prose.js +143 -0
  32. package/lib/types/prose.js.map +1 -0
  33. package/lib/types/rules.d.ts +226 -0
  34. package/lib/types/rules.d.ts.map +1 -0
  35. package/lib/types/rules.js +581 -0
  36. package/lib/types/rules.js.map +1 -0
  37. package/lib/types/runtime.d.ts +175 -0
  38. package/lib/types/runtime.d.ts.map +1 -0
  39. package/lib/types/runtime.js +497 -0
  40. package/lib/types/runtime.js.map +1 -0
  41. package/package.json +103 -0
  42. package/scripts/repair-session-logs.mjs +327 -0
  43. package/src/config.ts +141 -0
  44. package/src/events.ts +75 -0
  45. package/src/glob.ts +303 -0
  46. package/src/index.ts +27 -0
  47. package/src/prose.ts +206 -0
  48. package/src/rules.ts +695 -0
  49. 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
+ }