dsh-permission-rules 0.2.0 → 0.3.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/src/prose.ts CHANGED
@@ -24,6 +24,8 @@ export interface UiProse {
24
24
  reloadFailed: (error: string) => string
25
25
  /** Warning appended to a listing whose last reload failed. */
26
26
  lastReloadWarning: (error: string) => string
27
+ /** Notice appended to a listing when dry-run mode (`enforce: false`) is active. */
28
+ dryRunNotice: string
27
29
  /** Rejection of an unknown `/rules` argument. */
28
30
  unknownArg: (arg: string) => string
29
31
  /** Usage hint. */
@@ -34,14 +36,18 @@ export interface UiProse {
34
36
  noDecisions: string
35
37
  /** Rejection of a malformed decisions count. */
36
38
  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
+ /** One audit-decision row (dry-run rows mark the actual outcome). */
40
+ decisionLine: (seq: number, action: string, toolName: string, ruleIndex: number | undefined, reason: string | undefined, dryRun: boolean, outcome: string | undefined) => string
39
41
  /** Hit line of `/rules test`. */
40
42
  testHit: (tool: string, ruleIndex: number, action: string, reason: string) => string
41
43
  /** No-match line of `/rules test`. */
42
44
  testNoMatch: (tool: string) => string
43
45
  /** Rejection of unparseable `/rules test` JSON arguments. */
44
46
  testBadJson: (text: string) => string
47
+ /** Rejection of an unknown `/rules test` flag. */
48
+ testUnknownFlag: (flag: string) => string
49
+ /** Rejection of a malformed `/rules test` flag (missing value, bad KEY=VALUE). */
50
+ testBadFlag: (flag: string) => string
45
51
  /** Usage hint of `/rules test`. */
46
52
  testUsage: string
47
53
  /** Warning naming shadowed (unreachable) rules by 1-based number. */
@@ -54,6 +60,7 @@ export interface UiProse {
54
60
  export interface DescribeTokens {
55
61
  allTools: string
56
62
  tools: string
63
+ agents: string
57
64
  params: string
58
65
  paths: string
59
66
  absent: string
@@ -75,20 +82,24 @@ const EN: UiProse = {
75
82
  reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
76
83
  reloadFailed: error => `Reload failed: ${error}. The previous rules are still active.`,
77
84
  lastReloadWarning: error => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
85
+ dryRunNotice: 'Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.',
78
86
  unknownArg: arg => `Unknown /rules argument "${arg}". ${EN.usage}`,
79
87
  usage: 'Usage: /rules [reload | decisions [n] | test <tool> <json-args>]',
80
88
  decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
81
89
  noDecisions: 'No permission decisions recorded in this session yet.',
82
90
  invalidDecisionsCount: arg => `Invalid decisions count "${arg}": give a positive integer (default 10).`,
83
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
91
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
84
92
  const rule = ruleIndex === undefined ? '' : ` (rule ${ruleIndex + 1})`
93
+ const dry = dryRun ? (outcome === undefined ? ' (dry-run)' : ` (dry-run → ${outcome})`) : ''
85
94
  const why = reason === undefined ? '' : `: ${short(reason)}`
86
- return `- #${seq} ${action} ${toolName}${rule}${why}`
95
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
87
96
  },
88
97
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
89
98
  testNoMatch: tool => `"${tool}" matches no rule — the call passes through.`,
90
99
  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"}',
100
+ testUnknownFlag: flag => `Unknown /rules test flag "${flag}". ${EN.testUsage}`,
101
+ testBadFlag: flag => `Invalid /rules test flag "${flag}": expected a value. ${EN.testUsage}`,
102
+ testUsage: 'Usage: /rules test [--cwd <dir>] [--env KEY=VALUE]... [--agent <selector>]... <tool> <json-args>, e.g. /rules test bash {"command":"git push origin main"}',
92
103
  unreachableWarning: numbers => `Warning: rule${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')} ${numbers.length > 1 ? 'are' : 'is'} unreachable (shadowed by an earlier catch-all rule).`,
93
104
  emptySource: '(no rule file — empty rule set)',
94
105
  }
@@ -100,20 +111,24 @@ const ZH: UiProse = {
100
111
  reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
101
112
  reloadFailed: error => `重载失败:${error}。之前的规则仍然生效。`,
102
113
  lastReloadWarning: error => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
114
+ dryRunNotice: '干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。',
103
115
  unknownArg: arg => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
104
116
  usage: '用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]',
105
117
  decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
106
118
  noDecisions: '本会话还没有记录任何权限裁决。',
107
119
  invalidDecisionsCount: arg => `无效的裁决条数 "${arg}":请给正整数(默认 10)。`,
108
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
120
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
109
121
  const rule = ruleIndex === undefined ? '' : `(规则 ${ruleIndex + 1})`
122
+ const dry = dryRun ? (outcome === undefined ? '(干跑)' : `(干跑 → ${outcome})`) : ''
110
123
  const why = reason === undefined ? '' : `:${short(reason)}`
111
- return `- #${seq} ${action} ${toolName}${rule}${why}`
124
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
112
125
  },
113
126
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
114
127
  testNoMatch: tool => `"${tool}" 未命中任何规则——该调用将透传。`,
115
128
  testBadJson: text => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
116
- testUsage: '用法:/rules test <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
129
+ testUnknownFlag: flag => `未知的 /rules test 标志 "${flag}"。${ZH.testUsage}`,
130
+ testBadFlag: flag => `无效的 /rules test 标志 "${flag}":缺少取值。${ZH.testUsage}`,
131
+ testUsage: '用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
117
132
  unreachableWarning: numbers => `警告:规则 ${numbers.join('、')} 不可达(被前面的通配规则遮蔽)。`,
118
133
  emptySource: '(无规则文件——空规则集)',
119
134
  }
@@ -125,20 +140,24 @@ const ES: UiProse = {
125
140
  reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
126
141
  reloadFailed: error => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
127
142
  lastReloadWarning: error => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
143
+ dryRunNotice: 'Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.',
128
144
  unknownArg: arg => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
129
145
  usage: 'Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]',
130
146
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
131
147
  noDecisions: 'Esta sesión aún no registra decisiones de permiso.',
132
148
  invalidDecisionsCount: arg => `Cantidad de decisiones inválida "${arg}": indique un entero positivo (por defecto 10).`,
133
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
149
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
134
150
  const rule = ruleIndex === undefined ? '' : ` (regla ${ruleIndex + 1})`
151
+ const dry = dryRun ? (outcome === undefined ? ' (simulación)' : ` (simulación → ${outcome})`) : ''
135
152
  const why = reason === undefined ? '' : `: ${short(reason)}`
136
- return `- #${seq} ${action} ${toolName}${rule}${why}`
153
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
137
154
  },
138
155
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
139
156
  testNoMatch: tool => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
140
157
  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"}',
158
+ testUnknownFlag: flag => `Bandera de /rules test desconocida "${flag}". ${ES.testUsage}`,
159
+ testBadFlag: flag => `Bandera de /rules test inválida "${flag}": falta un valor. ${ES.testUsage}`,
160
+ testUsage: 'Uso: /rules test [--cwd <dir>] [--env CLAVE=VALOR]... [--agent <selector>]... <herramienta> <json-args>, p. ej. /rules test bash {"command":"git push origin main"}',
142
161
  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
162
  emptySource: '(sin archivo de reglas — conjunto vacío)',
144
163
  }
@@ -150,20 +169,24 @@ const PT: UiProse = {
150
169
  reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
151
170
  reloadFailed: error => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
152
171
  lastReloadWarning: error => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
172
+ dryRunNotice: 'Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.',
153
173
  unknownArg: arg => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
154
174
  usage: 'Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]',
155
175
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
156
176
  noDecisions: 'Esta sessão ainda não registrou decisões de permissão.',
157
177
  invalidDecisionsCount: arg => `Quantidade de decisões inválida "${arg}": informe um inteiro positivo (padrão 10).`,
158
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
178
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
159
179
  const rule = ruleIndex === undefined ? '' : ` (regra ${ruleIndex + 1})`
180
+ const dry = dryRun ? (outcome === undefined ? ' (simulação)' : ` (simulação → ${outcome})`) : ''
160
181
  const why = reason === undefined ? '' : `: ${short(reason)}`
161
- return `- #${seq} ${action} ${toolName}${rule}${why}`
182
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
162
183
  },
163
184
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
164
185
  testNoMatch: tool => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
165
186
  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"}',
187
+ testUnknownFlag: flag => `Sinalizador de /rules test desconhecido "${flag}". ${PT.testUsage}`,
188
+ testBadFlag: flag => `Sinalizador de /rules test inválido "${flag}": falta um valor. ${PT.testUsage}`,
189
+ testUsage: 'Uso: /rules test [--cwd <dir>] [--env CHAVE=VALOR]... [--agent <seletor>]... <ferramenta> <json-args>, ex.: /rules test bash {"command":"git push origin main"}',
167
190
  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
191
  emptySource: '(sem arquivo de regras — conjunto vazio)',
169
192
  }
@@ -175,20 +198,24 @@ const HI: UiProse = {
175
198
  reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
176
199
  reloadFailed: error => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
177
200
  lastReloadWarning: error => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
201
+ dryRunNotice: 'ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।',
178
202
  unknownArg: arg => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
179
203
  usage: 'उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]',
180
204
  decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
181
205
  noDecisions: 'इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।',
182
206
  invalidDecisionsCount: arg => `अमान्य निर्णय-संख्या "${arg}": एक धनात्मक पूर्णांक दें (डिफ़ॉल्ट 10)।`,
183
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
207
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
184
208
  const rule = ruleIndex === undefined ? '' : ` (नियम ${ruleIndex + 1})`
209
+ const dry = dryRun ? (outcome === undefined ? ' (ड्राई-रन)' : ` (ड्राई-रन → ${outcome})`) : ''
185
210
  const why = reason === undefined ? '' : `: ${short(reason)}`
186
- return `- #${seq} ${action} ${toolName}${rule}${why}`
211
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
187
212
  },
188
213
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
189
214
  testNoMatch: tool => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
190
215
  testBadJson: text => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
191
- testUsage: 'उपयोग: /rules test <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
216
+ testUnknownFlag: flag => `अज्ञात /rules test फ़्लैग "${flag}"। ${HI.testUsage}`,
217
+ testBadFlag: flag => `अमान्य /rules test फ़्लैग "${flag}": मान गायब है। ${HI.testUsage}`,
218
+ testUsage: 'उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
192
219
  unreachableWarning: numbers => `चेतावनी: नियम ${numbers.join(', ')} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
193
220
  emptySource: '(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)',
194
221
  }
@@ -198,9 +225,9 @@ export const UI_PROSE: Readonly<Record<UiLanguage, UiProse>> = { en: EN, zh: ZH,
198
225
 
199
226
  /** `describeRule` match-dimension tokens by language. */
200
227
  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: 'टैग' },
228
+ en: { allTools: 'all tools', tools: 'tools', agents: 'agents', params: 'params', paths: 'paths', absent: 'absent', when: 'when', platform: 'platform', disabled: 'disabled', tags: 'tags' },
229
+ zh: { allTools: '全部工具', tools: '工具', agents: '代理', params: '参数', paths: '路径', absent: '缺省键', when: '条件', platform: '平台', disabled: '已禁用', tags: '标签' },
230
+ es: { allTools: 'todas las herramientas', tools: 'herramientas', agents: 'agentes', params: 'parámetros', paths: 'rutas', absent: 'ausentes', when: 'cuándo', platform: 'plataforma', disabled: 'deshabilitada', tags: 'etiquetas' },
231
+ pt: { allTools: 'todas as ferramentas', tools: 'ferramentas', agents: 'agentes', params: 'parâmetros', paths: 'caminhos', absent: 'ausentes', when: 'quando', platform: 'plataforma', disabled: 'desativada', tags: 'etiquetas' },
232
+ hi: { allTools: 'सभी टूल', tools: 'टूल', agents: 'एजेंट', params: 'पैरामीटर', paths: 'पथ', absent: 'अनुपस्थित', when: 'शर्त', platform: 'प्लेटफ़ॉर्म', disabled: 'अक्षम', tags: 'टैग' },
206
233
  }
package/src/rules.ts CHANGED
@@ -68,6 +68,14 @@ export interface RuleMatchDoc {
68
68
  paths: string[]
69
69
  /** Argument keys that must be ABSENT; every listed key must be missing. */
70
70
  absent: string[]
71
+ /**
72
+ * Agent-identity selectors (globs against the calling agent's session
73
+ * header: `main`, `subagent`, and `preset:<name>` candidates); ANY
74
+ * selector matching ANY candidate satisfies the dimension. Empty matches
75
+ * every agent. Unknown agent identity never matches, so agent-scoped
76
+ * rules fail closed.
77
+ */
78
+ agents: string[]
71
79
  /** Environment/platform conditions; every listed env key must be present and match. */
72
80
  when: WhenDoc
73
81
  }
@@ -92,6 +100,8 @@ export interface CompiledRule {
92
100
  readonly description?: string
93
101
  readonly tags: readonly string[]
94
102
  readonly tools: readonly RegExp[]
103
+ /** Agent-selector globs; empty = every agent. */
104
+ readonly agents: readonly RegExp[]
95
105
  /** Param key → positive/negative pattern lists. */
96
106
  readonly params: ReadonlyMap<string, { positive: readonly RegExp[]; negative: readonly RegExp[] }>
97
107
  readonly paths: readonly RegExp[]
@@ -137,6 +147,12 @@ export interface MatchContext {
137
147
  readonly platform?: string
138
148
  /** Environment table; defaults to `process.env` when omitted. */
139
149
  readonly env?: Readonly<Record<string, string | undefined>>
150
+ /**
151
+ * Agent-identity candidate strings the `agents` dimension matches
152
+ * against (`main`, `subagent`, `preset:<name>`); omitted (or empty)
153
+ * means the identity is unknown and agent-scoped rules never match.
154
+ */
155
+ readonly agents?: readonly string[]
140
156
  }
141
157
 
142
158
  /**
@@ -207,6 +223,7 @@ export function compileRulesChain(entries: readonly RulesChainEntry[], options:
207
223
  /** Compile one parsed rule into its hot-path form. */
208
224
  function compileRule(entry: RuleDocEntry, index: number, sourceIndex: number, options: CompileOptions): CompiledRule {
209
225
  const tools = entry.match.tools.map(pattern => compileToolPattern(pattern, options.maxGlobStars))
226
+ const agents = entry.match.agents.map(pattern => compileToolPattern(pattern, options.maxGlobStars))
210
227
  const params = new Map<string, { positive: readonly RegExp[]; negative: readonly RegExp[] }>()
211
228
  for (const [key, patterns] of Object.entries(entry.match.params)) {
212
229
  params.set(key, compileParamPatterns(key, patterns, options))
@@ -225,6 +242,7 @@ function compileRule(entry: RuleDocEntry, index: number, sourceIndex: number, op
225
242
  ...(entry.description !== undefined ? { description: entry.description } : {}),
226
243
  tags: entry.tags,
227
244
  tools,
245
+ agents,
228
246
  params,
229
247
  paths,
230
248
  absent: entry.match.absent,
@@ -263,6 +281,7 @@ export function matchRules(
263
281
  for (const rule of ruleset.rules) {
264
282
  if (!rule.enabled) continue
265
283
  if (!matchTools(rule, toolName)) continue
284
+ if (!matchAgents(rule, context)) continue
266
285
  if (!matchWhen(rule, context)) continue
267
286
  if (rule.params.size > 0 && (argRecord === undefined || !matchParams(rule, argRecord))) continue
268
287
  if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue
@@ -296,7 +315,7 @@ export function findUnreachableRules(ruleset: CompiledRuleset): number[] {
296
315
 
297
316
  /** Whether every match dimension of one rule is empty. */
298
317
  function isCatchAll(rule: CompiledRule): boolean {
299
- return rule.tools.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === undefined
318
+ return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === undefined
300
319
  }
301
320
 
302
321
  /**
@@ -310,6 +329,7 @@ function isCatchAll(rule: CompiledRule): boolean {
310
329
  export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string {
311
330
  const parts: string[] = []
312
331
  if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(',')}`)
332
+ if (rule.source.match.agents.length > 0) parts.push(`${tokens.agents}:${rule.source.match.agents.join(',')}`)
313
333
  const params = Object.entries(rule.source.match.params)
314
334
  .map(([key, patterns]) => `${key}=${patterns.join('|')}`)
315
335
  .join(' ')
@@ -332,6 +352,7 @@ export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string
332
352
  export interface DescribeTokens {
333
353
  readonly allTools: string
334
354
  readonly tools: string
355
+ readonly agents: string
335
356
  readonly params: string
336
357
  readonly paths: string
337
358
  readonly absent: string
@@ -352,6 +373,19 @@ function matchTools(rule: CompiledRule, toolName: string): boolean {
352
373
  return rule.tools.some(regex => regex.test(toolName))
353
374
  }
354
375
 
376
+ /**
377
+ * Whether a rule's agents dimension selects the caller. Any selector glob
378
+ * matching any identity candidate satisfies the dimension; unknown
379
+ * identity (no agent or no candidates) fails the dimension, so an
380
+ * agent-scoped rule can never fire where the caller cannot be identified.
381
+ */
382
+ function matchAgents(rule: CompiledRule, context: MatchContext): boolean {
383
+ if (rule.agents.length === 0) return true
384
+ const candidates = context.agents
385
+ if (candidates === undefined || candidates.length === 0) return false
386
+ return rule.agents.some(regex => candidates.some(candidate => regex.test(candidate)))
387
+ }
388
+
355
389
  /** Whether a rule's params dimension holds: EVERY key present and matching. */
356
390
  function matchParams(rule: CompiledRule, args: Record<string, unknown>): boolean {
357
391
  for (const [key, { positive, negative }] of rule.params) {
@@ -474,14 +508,15 @@ function parseRuleEntry(raw: unknown, index: number): RuleDocEntry {
474
508
 
475
509
  /** Validate the `match` block of one rule. */
476
510
  function parseMatch(raw: unknown, at: string): RuleMatchDoc {
477
- if (raw === undefined) return { tools: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
511
+ if (raw === undefined) return { tools: [], agents: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
478
512
  const record = asRecord(raw, `${at}.match`)
479
- const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
513
+ const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'agents' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
480
514
  if (unknownFields.length > 0) {
481
- throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: tools, params, paths, absent, when)`)
515
+ throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: tools, agents, params, paths, absent, when)`)
482
516
  }
483
517
  return {
484
518
  tools: parseStringList(record['tools'], `${at}.match.tools`),
519
+ agents: parseStringList(record['agents'], `${at}.match.agents`),
485
520
  params: parseParams(record['params'], `${at}.match.params`),
486
521
  paths: parseStringList(record['paths'], `${at}.match.paths`),
487
522
  absent: parseStringList(record['absent'], `${at}.match.absent`),
@@ -671,7 +706,10 @@ function relFromRoot(root: string, path: string, rest: string, rootRest: string,
671
706
  const restLower = caseInsensitive ? rest.toLowerCase() : rest
672
707
  const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest
673
708
  if (rootRestLower.length === 0) return rest.slice(1)
674
- if (path.toLowerCase() === root.toLowerCase()) return ''
709
+ // A candidate equal to the root itself — modulo case only when the
710
+ // comparison is case-insensitive — is the workspace root, not a relative
711
+ // path inside it, and is dropped explicitly.
712
+ if (path === root || (caseInsensitive && path.toLowerCase() === root.toLowerCase())) return ''
675
713
  return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : ''
676
714
  }
677
715
 
package/src/runtime.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * `tools/pre-execute` listener that turns a first-match hit into a
5
5
  * deny/ask decision (and NEVER short-circuits on allow or passthrough),
6
6
  * the `permissionRules/decision` audit event, the `/rules` session command
7
- * (`list | reload | decisions [n] | test <tool> <json>`), and
7
+ * (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
8
8
  * Chokidar-driven reloads. Every registration is an effect.
9
9
  * @module dsh-permission-rules/runtime
10
10
  */
@@ -189,17 +189,26 @@ export class PermissionRulesRuntime {
189
189
  }
190
190
  }
191
191
 
192
- /** The match context `when` conditions evaluate against. */
193
- private matchContext(): MatchContext {
194
- return { platform: process.platform, env: process.env }
192
+ /**
193
+ * The match context `when`/`agents` conditions evaluate against: host
194
+ * facts plus the caller's agent-identity candidates derived from the
195
+ * session header (`main` for top-level sessions, `subagent` for
196
+ * subagent children, `preset:<name>` when a preset composed the agent).
197
+ * @param exec - the pending call whose caller supplies the identity.
198
+ */
199
+ private matchContext(exec?: ToolExecution): MatchContext {
200
+ return { platform: process.platform, env: process.env, agents: agentCandidates(exec?.agent) }
195
201
  }
196
202
 
197
203
  /**
198
204
  * The `tools/pre-execute` listener. A deny/ask hit returns the decision
199
205
  * (first match wins, short-circuiting downstream listeners); an allow hit
200
206
  * and a passthrough MUST delegate via `next()` so later listeners keep
201
- * their say. Audit is appended once the final outcome is known, so the
202
- * recorded `outcome` matches what the waterfall settled on.
207
+ * their say. Under `enforce: false` (dry-run) a deny/ask hit also
208
+ * delegates — the record keeps the would-be action with `dryRun: true`
209
+ * and the actual downstream outcome. Audit is appended once the final
210
+ * outcome is known, so the recorded `outcome` matches what the waterfall
211
+ * settled on.
203
212
  * @param exec - the pending call (name, parsed arguments, caller agent).
204
213
  * @param next - the downstream chain.
205
214
  * @returns the pre-execute decision.
@@ -207,12 +216,18 @@ export class PermissionRulesRuntime {
207
216
  async preExecute(exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision> {
208
217
  const cwd = exec.agent?.session.header.cwd ?? process.cwd()
209
218
  const loaded = this.rulesFor(cwd)
210
- const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext())
219
+ const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec))
211
220
  if (hit === undefined || hit.rule.action === 'allow') {
212
221
  const decision = await next()
213
222
  this.audit(exec, loaded, hit, decision.kind)
214
223
  return decision
215
224
  }
225
+ if (!this.config.enforce) {
226
+ // Dry-run: match the rule, log what it WOULD do, and delegate.
227
+ const decision = await next()
228
+ this.audit(exec, loaded, hit, decision.kind, true)
229
+ return decision
230
+ }
216
231
  const outcome: DecisionOutcome = hit.rule.action
217
232
  this.audit(exec, loaded, hit, outcome)
218
233
  if (outcome === 'deny') return { kind: 'deny', reason: hit.rule.reason }
@@ -225,15 +240,18 @@ export class PermissionRulesRuntime {
225
240
  * envelope's `ignorable: true` marker so any harness build can load the
226
241
  * log (readers that do not know the type skip the audit record instead of
227
242
  * refusing the session). `source` names the matched rule's file, or the
228
- * nearest effective file on a passthrough. Agentless calls have no
229
- * session to audit; append failures are contained so an audit hiccup can
230
- * never change a permission decision.
243
+ * nearest effective file on a passthrough; `cwd` names the workspace the
244
+ * rules were resolved for; `dryRun` marks would-be deny/ask hits under
245
+ * `enforce: false`. Agentless calls have no session to audit; append
246
+ * failures are contained so an audit hiccup can never change a
247
+ * permission decision.
231
248
  * @param exec - the pending call.
232
249
  * @param loaded - the rules in effect.
233
250
  * @param hit - the first matching rule, or undefined for passthrough.
234
251
  * @param outcome - the final pre-execute decision.
252
+ * @param dryRun - mark the record as a would-be decision (dry-run mode).
235
253
  */
236
- audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome): void {
254
+ audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome, dryRun = false): void {
237
255
  if (this.config.audit === 'hits' && hit === undefined) return
238
256
  const agent = exec.agent
239
257
  if (agent === undefined) return
@@ -244,7 +262,9 @@ export class PermissionRulesRuntime {
244
262
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
245
263
  action: hit === undefined ? 'passthrough' : hit.rule.action,
246
264
  outcome,
265
+ cwd: loaded.cwd,
247
266
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
267
+ ...dryRun ? { dryRun: true as const } : {},
248
268
  }, { ignorable: true })
249
269
  } catch (error: unknown) {
250
270
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
@@ -254,8 +274,9 @@ export class PermissionRulesRuntime {
254
274
  /**
255
275
  * Execute the `/rules` command: bare `/rules` lists the active rules and
256
276
  * their sources; `/rules reload` re-reads the chain; `/rules decisions
257
- * [n]` shows the session's audit trail; `/rules test <tool> <json>`
258
- * dry-evaluates the rules against a hypothetical call. Command output
277
+ * [n]` shows the session's audit trail; `/rules test [flags] <tool>
278
+ * <json>` dry-evaluates the rules against a hypothetical call (flags
279
+ * override the workspace, host env, and agent identity). Command output
259
280
  * stays in the UI — nothing here is injected into the model context.
260
281
  * @param invocation - the received command invocation.
261
282
  * @returns the command result shown to the user.
@@ -286,29 +307,7 @@ export class PermissionRulesRuntime {
286
307
  return this.decisionsCommand(invocation, count, prose)
287
308
  }
288
309
  if (verb === 'test') {
289
- const verbText = verbRaw ?? ''
290
- const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : ''
291
- const match = /^(\S+)\s*(.*)$/s.exec(restLine)
292
- const tool = match?.[1]
293
- const jsonText = match?.[2] ?? ''
294
- if (tool === undefined || tool.length === 0) return { kind: 'error', text: prose.testUsage }
295
- let args: unknown
296
- try {
297
- args = jsonText.length > 0 ? JSON.parse(jsonText) : {}
298
- } catch {
299
- return { kind: 'error', text: prose.testBadJson(jsonText) }
300
- }
301
- let loaded: LoadedRules
302
- try {
303
- loaded = this.rulesFor(cwd)
304
- } catch (error: unknown) {
305
- return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
306
- }
307
- const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext())
308
- return {
309
- kind: 'success',
310
- text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
311
- }
310
+ return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose)
312
311
  }
313
312
  if (verb !== '') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
314
313
  let loaded: LoadedRules
@@ -329,10 +328,81 @@ export class PermissionRulesRuntime {
329
328
  if (loaded.lastError !== undefined) {
330
329
  lines.push(prose.lastReloadWarning(loaded.lastError))
331
330
  }
331
+ if (!this.config.enforce) {
332
+ lines.push(prose.dryRunNotice)
333
+ }
332
334
  lines.push(prose.usage)
333
335
  return { kind: 'success', text: lines.join('\n') }
334
336
  }
335
337
 
338
+ /**
339
+ * Execute `/rules test` with optional leading flags: `--cwd <dir>`
340
+ * evaluates against that workspace (rule discovery AND path
341
+ * normalization), `--env KEY=VALUE` (repeatable) overrides host env for
342
+ * `when.env` matching, and `--agent <selector>` (repeatable) supplies
343
+ * agent-identity candidates for the `agents` dimension. The JSON
344
+ * argument tail is kept verbatim, so quoted JSON survives unchanged.
345
+ * @param raw - the full raw command input.
346
+ * @param verbRaw - the verb as typed.
347
+ * @param invocation - the received command invocation (session agent).
348
+ * @param sessionCwd - the session's workspace root.
349
+ * @param prose - localized output vocabulary.
350
+ * @returns the dry-evaluation result.
351
+ */
352
+ private testCommand(raw: string, verbRaw: string, invocation: CommandInvocation, sessionCwd: string, prose: UiProse): CommandResult {
353
+ let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : ''
354
+ const envOverrides: Record<string, string> = {}
355
+ const agentSelectors: string[] = []
356
+ let testCwd: string | undefined
357
+ let parsed = nextToken(rest)
358
+ while (parsed !== undefined && parsed.token.startsWith('--')) {
359
+ const flag = parsed.token
360
+ const value = nextToken(parsed.rest)
361
+ if (value === undefined || value.token.startsWith('--')) {
362
+ return { kind: 'error', text: prose.testBadFlag(flag) }
363
+ }
364
+ if (flag === '--cwd') {
365
+ testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token)
366
+ } else if (flag === '--env') {
367
+ const equals = value.token.indexOf('=')
368
+ if (equals <= 0) return { kind: 'error', text: prose.testBadFlag(`${flag} ${value.token}`) }
369
+ envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1)
370
+ } else if (flag === '--agent') {
371
+ agentSelectors.push(value.token)
372
+ } else {
373
+ return { kind: 'error', text: prose.testUnknownFlag(flag) }
374
+ }
375
+ rest = value.rest
376
+ parsed = nextToken(rest)
377
+ }
378
+ const tool = parsed?.token
379
+ const jsonText = parsed === undefined ? '' : parsed.rest.trim()
380
+ if (tool === undefined || tool.length === 0) return { kind: 'error', text: prose.testUsage }
381
+ let args: unknown
382
+ try {
383
+ args = jsonText.length > 0 ? JSON.parse(jsonText) : {}
384
+ } catch {
385
+ return { kind: 'error', text: prose.testBadJson(jsonText) }
386
+ }
387
+ const evalCwd = testCwd ?? sessionCwd
388
+ let loaded: LoadedRules
389
+ try {
390
+ loaded = this.rulesFor(evalCwd)
391
+ } catch (error: unknown) {
392
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
393
+ }
394
+ const context: MatchContext = {
395
+ platform: process.platform,
396
+ env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
397
+ agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
398
+ }
399
+ const hit = matchRules(loaded.compiled, tool, args, evalCwd, context)
400
+ return {
401
+ kind: 'success',
402
+ text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
403
+ }
404
+ }
405
+
336
406
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
337
407
  private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
338
408
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
@@ -341,7 +411,7 @@ export class PermissionRulesRuntime {
341
411
  const lines = [prose.decisionsHeader(shown.length, decisions.length)]
342
412
  for (const event of shown) {
343
413
  const data = event.data as AuditDecision
344
- lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason))
414
+ lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
345
415
  }
346
416
  return { kind: 'success', text: lines.join('\n') }
347
417
  }
@@ -498,7 +568,53 @@ export function apply(ctx: Context, config: Config): void {
498
568
  ctx.commands.register({
499
569
  name: 'rules',
500
570
  description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
501
- input: { hint: '[reload | decisions [n] | test <tool> <json-args>]' },
571
+ input: { hint: '[reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] <tool> <json-args>]' },
502
572
  handler: invocation => runtime.command(invocation),
503
573
  })
504
574
  }
575
+
576
+ /**
577
+ * Identity candidates for the `agents` match dimension, derived from the
578
+ * caller agent's session header: `main` for top-level sessions, `subagent`
579
+ * for subagent children (`header.origin === 'subagent'`), and
580
+ * `preset:<name>` when a preset composed the agent. No agent (or an
581
+ * unidentifiable one) yields no candidates, so agent-scoped rules fail
582
+ * closed instead of matching an unknown caller.
583
+ * @param agent - the calling agent, when the call has one.
584
+ * @returns the candidate strings, in a stable order.
585
+ */
586
+ function agentCandidates(agent: ToolExecution['agent']): string[] {
587
+ if (agent === undefined) return []
588
+ const header = agent.session.header
589
+ const candidates = [header.origin === 'subagent' ? 'subagent' : 'main']
590
+ if (typeof header.agentPreset === 'string' && header.agentPreset.length > 0) candidates.push(`preset:${header.agentPreset}`)
591
+ return candidates
592
+ }
593
+
594
+ /**
595
+ * Pull the next whitespace-delimited argument from a command tail,
596
+ * honoring single/double quotes (with backslash escapes) so JSON blobs and
597
+ * paths with spaces survive. The remainder is returned verbatim — the JSON
598
+ * argument tail of `/rules test` is never re-tokenized.
599
+ * @param text - the remaining command tail.
600
+ * @returns the token plus the untouched remainder, or undefined when only
601
+ * whitespace remains.
602
+ */
603
+ function nextToken(text: string): { token: string; rest: string } | undefined {
604
+ let i = 0
605
+ while (i < text.length && /\s/.test(text[i] as string)) i += 1
606
+ if (i >= text.length) return undefined
607
+ const quote = text[i] as string
608
+ if (quote === '"' || quote === "'") {
609
+ let end = i + 1
610
+ while (end < text.length && text[end] !== quote) {
611
+ if (text[end] === '\\') end += 1
612
+ end += 1
613
+ }
614
+ if (end >= text.length) return { token: text.slice(i + 1), rest: '' }
615
+ return { token: text.slice(i + 1, end), rest: text.slice(end + 1) }
616
+ }
617
+ let end = i
618
+ while (end < text.length && !/\s/.test(text[end] as string)) end += 1
619
+ return { token: text.slice(i, end), rest: text.slice(end) }
620
+ }