dsh-permission-rules 0.2.0 → 0.4.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,20 @@ 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
51
+ /** Rejection of an unknown `--platform` value. */
52
+ testBadPlatform: (value: string) => string
45
53
  /** Usage hint of `/rules test`. */
46
54
  testUsage: string
47
55
  /** Warning naming shadowed (unreachable) rules by 1-based number. */
@@ -54,6 +62,7 @@ export interface UiProse {
54
62
  export interface DescribeTokens {
55
63
  allTools: string
56
64
  tools: string
65
+ agents: string
57
66
  params: string
58
67
  paths: string
59
68
  absent: string
@@ -61,6 +70,8 @@ export interface DescribeTokens {
61
70
  platform: string
62
71
  disabled: string
63
72
  tags: string
73
+ /** Prefix of the per-rule source-file attribution. */
74
+ src: string
64
75
  }
65
76
 
66
77
  /** Truncate long reasons/descriptions in one-line displays. */
@@ -75,20 +86,25 @@ const EN: UiProse = {
75
86
  reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
76
87
  reloadFailed: error => `Reload failed: ${error}. The previous rules are still active.`,
77
88
  lastReloadWarning: error => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
89
+ dryRunNotice: 'Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.',
78
90
  unknownArg: arg => `Unknown /rules argument "${arg}". ${EN.usage}`,
79
- usage: 'Usage: /rules [reload | decisions [n] | test <tool> <json-args>]',
91
+ usage: 'Usage: /rules [list | reload | decisions [n] | test <tool> <json-args>]',
80
92
  decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
81
93
  noDecisions: 'No permission decisions recorded in this session yet.',
82
94
  invalidDecisionsCount: arg => `Invalid decisions count "${arg}": give a positive integer (default 10).`,
83
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
95
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
84
96
  const rule = ruleIndex === undefined ? '' : ` (rule ${ruleIndex + 1})`
97
+ const dry = dryRun ? (outcome === undefined ? ' (dry-run)' : ` (dry-run → ${outcome})`) : ''
85
98
  const why = reason === undefined ? '' : `: ${short(reason)}`
86
- return `- #${seq} ${action} ${toolName}${rule}${why}`
99
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
87
100
  },
88
101
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
89
102
  testNoMatch: tool => `"${tool}" matches no rule — the call passes through.`,
90
103
  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"}',
104
+ testUnknownFlag: flag => `Unknown /rules test flag "${flag}". ${EN.testUsage}`,
105
+ testBadFlag: flag => `Invalid /rules test flag "${flag}": expected a value. ${EN.testUsage}`,
106
+ testBadPlatform: value => `Unknown platform "${value}" in --platform: expected one of aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${EN.testUsage}`,
107
+ testUsage: 'Usage: /rules test [--cwd <dir>] [--env KEY=VALUE]... [--agent <selector>]... [--platform <name>] <tool> <json-args>, e.g. /rules test bash {"command":"git push origin main"}',
92
108
  unreachableWarning: numbers => `Warning: rule${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')} ${numbers.length > 1 ? 'are' : 'is'} unreachable (shadowed by an earlier catch-all rule).`,
93
109
  emptySource: '(no rule file — empty rule set)',
94
110
  }
@@ -100,20 +116,25 @@ const ZH: UiProse = {
100
116
  reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
101
117
  reloadFailed: error => `重载失败:${error}。之前的规则仍然生效。`,
102
118
  lastReloadWarning: error => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
119
+ dryRunNotice: '干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。',
103
120
  unknownArg: arg => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
104
- usage: '用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]',
121
+ usage: '用法:/rules [list | reload | decisions [n] | test <工具名> <json-参数>]',
105
122
  decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
106
123
  noDecisions: '本会话还没有记录任何权限裁决。',
107
124
  invalidDecisionsCount: arg => `无效的裁决条数 "${arg}":请给正整数(默认 10)。`,
108
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
125
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
109
126
  const rule = ruleIndex === undefined ? '' : `(规则 ${ruleIndex + 1})`
127
+ const dry = dryRun ? (outcome === undefined ? '(干跑)' : `(干跑 → ${outcome})`) : ''
110
128
  const why = reason === undefined ? '' : `:${short(reason)}`
111
- return `- #${seq} ${action} ${toolName}${rule}${why}`
129
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
112
130
  },
113
131
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
114
132
  testNoMatch: tool => `"${tool}" 未命中任何规则——该调用将透传。`,
115
133
  testBadJson: text => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
116
- testUsage: '用法:/rules test <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
134
+ testUnknownFlag: flag => `未知的 /rules test 标志 "${flag}"。${ZH.testUsage}`,
135
+ testBadFlag: flag => `无效的 /rules test 标志 "${flag}":缺少取值。${ZH.testUsage}`,
136
+ testBadPlatform: value => `未知的平台 "${value}"(--platform):应为 aix、android、darwin、freebsd、linux、openbsd、sunos、win32 之一。${ZH.testUsage}`,
137
+ testUsage: '用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... [--platform <平台名>] <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
117
138
  unreachableWarning: numbers => `警告:规则 ${numbers.join('、')} 不可达(被前面的通配规则遮蔽)。`,
118
139
  emptySource: '(无规则文件——空规则集)',
119
140
  }
@@ -125,20 +146,25 @@ const ES: UiProse = {
125
146
  reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
126
147
  reloadFailed: error => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
127
148
  lastReloadWarning: error => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
149
+ dryRunNotice: 'Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.',
128
150
  unknownArg: arg => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
129
- usage: 'Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]',
151
+ usage: 'Uso: /rules [list | reload | decisions [n] | test <herramienta> <json-args>]',
130
152
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
131
153
  noDecisions: 'Esta sesión aún no registra decisiones de permiso.',
132
154
  invalidDecisionsCount: arg => `Cantidad de decisiones inválida "${arg}": indique un entero positivo (por defecto 10).`,
133
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
155
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
134
156
  const rule = ruleIndex === undefined ? '' : ` (regla ${ruleIndex + 1})`
157
+ const dry = dryRun ? (outcome === undefined ? ' (simulación)' : ` (simulación → ${outcome})`) : ''
135
158
  const why = reason === undefined ? '' : `: ${short(reason)}`
136
- return `- #${seq} ${action} ${toolName}${rule}${why}`
159
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
137
160
  },
138
161
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
139
162
  testNoMatch: tool => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
140
163
  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"}',
164
+ testUnknownFlag: flag => `Bandera de /rules test desconocida "${flag}". ${ES.testUsage}`,
165
+ testBadFlag: flag => `Bandera de /rules test inválida "${flag}": falta un valor. ${ES.testUsage}`,
166
+ testBadPlatform: value => `Plataforma desconocida "${value}" en --platform: se esperaba una de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${ES.testUsage}`,
167
+ testUsage: 'Uso: /rules test [--cwd <dir>] [--env CLAVE=VALOR]... [--agent <selector>]... [--platform <nombre>] <herramienta> <json-args>, p. ej. /rules test bash {"command":"git push origin main"}',
142
168
  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
169
  emptySource: '(sin archivo de reglas — conjunto vacío)',
144
170
  }
@@ -150,20 +176,25 @@ const PT: UiProse = {
150
176
  reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
151
177
  reloadFailed: error => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
152
178
  lastReloadWarning: error => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
179
+ dryRunNotice: 'Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.',
153
180
  unknownArg: arg => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
154
- usage: 'Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]',
181
+ usage: 'Uso: /rules [list | reload | decisions [n] | test <ferramenta> <json-args>]',
155
182
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
156
183
  noDecisions: 'Esta sessão ainda não registrou decisões de permissão.',
157
184
  invalidDecisionsCount: arg => `Quantidade de decisões inválida "${arg}": informe um inteiro positivo (padrão 10).`,
158
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
185
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
159
186
  const rule = ruleIndex === undefined ? '' : ` (regra ${ruleIndex + 1})`
187
+ const dry = dryRun ? (outcome === undefined ? ' (simulação)' : ` (simulação → ${outcome})`) : ''
160
188
  const why = reason === undefined ? '' : `: ${short(reason)}`
161
- return `- #${seq} ${action} ${toolName}${rule}${why}`
189
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
162
190
  },
163
191
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
164
192
  testNoMatch: tool => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
165
193
  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"}',
194
+ testUnknownFlag: flag => `Sinalizador de /rules test desconhecido "${flag}". ${PT.testUsage}`,
195
+ testBadFlag: flag => `Sinalizador de /rules test inválido "${flag}": falta um valor. ${PT.testUsage}`,
196
+ testBadPlatform: value => `Plataforma desconhecida "${value}" em --platform: esperava-se uma de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${PT.testUsage}`,
197
+ testUsage: 'Uso: /rules test [--cwd <dir>] [--env CHAVE=VALOR]... [--agent <seletor>]... [--platform <nome>] <ferramenta> <json-args>, ex.: /rules test bash {"command":"git push origin main"}',
167
198
  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
199
  emptySource: '(sem arquivo de regras — conjunto vazio)',
169
200
  }
@@ -175,20 +206,25 @@ const HI: UiProse = {
175
206
  reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
176
207
  reloadFailed: error => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
177
208
  lastReloadWarning: error => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
209
+ dryRunNotice: 'ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।',
178
210
  unknownArg: arg => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
179
- usage: 'उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]',
211
+ usage: 'उपयोग: /rules [list | reload | decisions [n] | test <टूल> <json-args>]',
180
212
  decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
181
213
  noDecisions: 'इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।',
182
214
  invalidDecisionsCount: arg => `अमान्य निर्णय-संख्या "${arg}": एक धनात्मक पूर्णांक दें (डिफ़ॉल्ट 10)।`,
183
- decisionLine: (seq, action, toolName, ruleIndex, reason) => {
215
+ decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
184
216
  const rule = ruleIndex === undefined ? '' : ` (नियम ${ruleIndex + 1})`
217
+ const dry = dryRun ? (outcome === undefined ? ' (ड्राई-रन)' : ` (ड्राई-रन → ${outcome})`) : ''
185
218
  const why = reason === undefined ? '' : `: ${short(reason)}`
186
- return `- #${seq} ${action} ${toolName}${rule}${why}`
219
+ return `- #${seq} ${action} ${toolName}${rule}${dry}${why}`
187
220
  },
188
221
  testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
189
222
  testNoMatch: tool => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
190
223
  testBadJson: text => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
191
- testUsage: 'उपयोग: /rules test <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
224
+ testUnknownFlag: flag => `अज्ञात /rules test फ़्लैग "${flag}"। ${HI.testUsage}`,
225
+ testBadFlag: flag => `अमान्य /rules test फ़्लैग "${flag}": मान गायब है। ${HI.testUsage}`,
226
+ testBadPlatform: value => `अज्ञात प्लेटफ़ॉर्म "${value}" (--platform): aix, android, darwin, freebsd, linux, openbsd, sunos, win32 में से एक अपेक्षित। ${HI.testUsage}`,
227
+ testUsage: 'उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... [--platform <नाम>] <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
192
228
  unreachableWarning: numbers => `चेतावनी: नियम ${numbers.join(', ')} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
193
229
  emptySource: '(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)',
194
230
  }
@@ -198,9 +234,9 @@ export const UI_PROSE: Readonly<Record<UiLanguage, UiProse>> = { en: EN, zh: ZH,
198
234
 
199
235
  /** `describeRule` match-dimension tokens by language. */
200
236
  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: 'टैग' },
237
+ en: { allTools: 'all tools', tools: 'tools', agents: 'agents', params: 'params', paths: 'paths', absent: 'absent', when: 'when', platform: 'platform', disabled: 'disabled', tags: 'tags', src: 'src' },
238
+ zh: { allTools: '全部工具', tools: '工具', agents: '代理', params: '参数', paths: '路径', absent: '缺省键', when: '条件', platform: '平台', disabled: '已禁用', tags: '标签', src: '来源' },
239
+ 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', src: 'origen' },
240
+ pt: { allTools: 'todas as ferramentas', tools: 'ferramentas', agents: 'agentes', params: 'parâmetros', paths: 'caminhos', absent: 'ausentes', when: 'quando', platform: 'plataforma', disabled: 'desativada', tags: 'etiquetas', src: 'origem' },
241
+ hi: { allTools: 'सभी टूल', tools: 'टूल', agents: 'एजेंट', params: 'पैरामीटर', paths: 'पथ', absent: 'अनुपस्थित', when: 'शर्त', platform: 'प्लेटफ़ॉर्म', disabled: 'अक्षम', tags: 'टैग', src: 'स्रोत' },
206
242
  }
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,20 +315,25 @@ 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
  /**
303
322
  * One-line human summary of a compiled rule, for `/rules` output. The
304
323
  * 1-based number, action, match dimensions, and reason render first; a
305
- * disabled marker, tags, and a truncated description follow.
324
+ * disabled marker, tags, and a truncated description follow. When `source`
325
+ * is provided (multi-file chains), the rule's origin file is attributed on
326
+ * the line.
306
327
  * @param rule - the compiled rule.
307
328
  * @param tokens - dimension-prefix vocabulary for the configured language.
329
+ * @param source - display path of the rule's source file, when the listing
330
+ * spans several files.
308
331
  * @returns a single display line.
309
332
  */
310
- export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string {
333
+ export function describeRule(rule: CompiledRule, tokens: DescribeTokens, source?: string): string {
311
334
  const parts: string[] = []
312
335
  if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(',')}`)
336
+ if (rule.source.match.agents.length > 0) parts.push(`${tokens.agents}:${rule.source.match.agents.join(',')}`)
313
337
  const params = Object.entries(rule.source.match.params)
314
338
  .map(([key, patterns]) => `${key}=${patterns.join('|')}`)
315
339
  .join(' ')
@@ -323,15 +347,17 @@ export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string
323
347
  if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? ' ' : ''}${platformPart}`)
324
348
  const match = parts.length > 0 ? `[${parts.join(' ')}]` : `[${tokens.allTools}]`
325
349
  const disabled = rule.enabled ? '' : ` (${tokens.disabled})`
350
+ const sourcePart = source !== undefined && source.length > 0 ? ` [${tokens.src}:${source}]` : ''
326
351
  const description = rule.description !== undefined ? ` (${truncate(rule.description, DISPLAY_TRUNCATE)})` : ''
327
352
  const tags = rule.tags.length > 0 ? ` [${tokens.tags}:${rule.tags.join(',')}]` : ''
328
- return `${rule.index + 1}. ${rule.action}${disabled} ${match}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`
353
+ return `${rule.index + 1}. ${rule.action}${disabled} ${match}${sourcePart}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`
329
354
  }
330
355
 
331
356
  /** The localized dimension-prefix vocabulary {@link describeRule} renders. */
332
357
  export interface DescribeTokens {
333
358
  readonly allTools: string
334
359
  readonly tools: string
360
+ readonly agents: string
335
361
  readonly params: string
336
362
  readonly paths: string
337
363
  readonly absent: string
@@ -339,6 +365,8 @@ export interface DescribeTokens {
339
365
  readonly platform: string
340
366
  readonly disabled: string
341
367
  readonly tags: string
368
+ /** Prefix of the per-rule source-file attribution. */
369
+ readonly src: string
342
370
  }
343
371
 
344
372
  /** Cut a display string at `limit` characters, marking truncation with `…`. */
@@ -352,6 +380,19 @@ function matchTools(rule: CompiledRule, toolName: string): boolean {
352
380
  return rule.tools.some(regex => regex.test(toolName))
353
381
  }
354
382
 
383
+ /**
384
+ * Whether a rule's agents dimension selects the caller. Any selector glob
385
+ * matching any identity candidate satisfies the dimension; unknown
386
+ * identity (no agent or no candidates) fails the dimension, so an
387
+ * agent-scoped rule can never fire where the caller cannot be identified.
388
+ */
389
+ function matchAgents(rule: CompiledRule, context: MatchContext): boolean {
390
+ if (rule.agents.length === 0) return true
391
+ const candidates = context.agents
392
+ if (candidates === undefined || candidates.length === 0) return false
393
+ return rule.agents.some(regex => candidates.some(candidate => regex.test(candidate)))
394
+ }
395
+
355
396
  /** Whether a rule's params dimension holds: EVERY key present and matching. */
356
397
  function matchParams(rule: CompiledRule, args: Record<string, unknown>): boolean {
357
398
  for (const [key, { positive, negative }] of rule.params) {
@@ -474,14 +515,15 @@ function parseRuleEntry(raw: unknown, index: number): RuleDocEntry {
474
515
 
475
516
  /** Validate the `match` block of one rule. */
476
517
  function parseMatch(raw: unknown, at: string): RuleMatchDoc {
477
- if (raw === undefined) return { tools: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
518
+ if (raw === undefined) return { tools: [], agents: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
478
519
  const record = asRecord(raw, `${at}.match`)
479
- const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
520
+ const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'agents' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
480
521
  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)`)
522
+ 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
523
  }
483
524
  return {
484
525
  tools: parseStringList(record['tools'], `${at}.match.tools`),
526
+ agents: parseStringList(record['agents'], `${at}.match.agents`),
485
527
  params: parseParams(record['params'], `${at}.match.params`),
486
528
  paths: parseStringList(record['paths'], `${at}.match.paths`),
487
529
  absent: parseStringList(record['absent'], `${at}.match.absent`),
@@ -671,7 +713,10 @@ function relFromRoot(root: string, path: string, rest: string, rootRest: string,
671
713
  const restLower = caseInsensitive ? rest.toLowerCase() : rest
672
714
  const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest
673
715
  if (rootRestLower.length === 0) return rest.slice(1)
674
- if (path.toLowerCase() === root.toLowerCase()) return ''
716
+ // A candidate equal to the root itself — modulo case only when the
717
+ // comparison is case-insensitive — is the workspace root, not a relative
718
+ // path inside it, and is dropped explicitly.
719
+ if (path === root || (caseInsensitive && path.toLowerCase() === root.toLowerCase())) return ''
675
720
  return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : ''
676
721
  }
677
722