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/CHANGELOG.md +21 -0
- package/README.es.md +8 -4
- package/README.hi.md +8 -4
- package/README.md +8 -4
- package/README.pt.md +8 -4
- package/README.zh.md +8 -4
- package/docs/rules-format.en.md +4 -1
- package/docs/rules-format.md +4 -1
- package/docs/rules-format.schema.json +5 -0
- package/lib/index.js +269 -79
- package/lib/types/config.d.ts +12 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +26 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/events.d.ts +14 -1
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js +5 -0
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +8 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/prose.d.ts +9 -2
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +40 -20
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +17 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +29 -5
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +38 -10
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +165 -41
- package/lib/types/runtime.js.map +1 -1
- package/package.json +103 -103
- package/src/config.ts +36 -2
- package/src/events.ts +14 -1
- package/src/index.ts +8 -6
- package/src/prose.ts +49 -22
- package/src/rules.ts +43 -5
- package/src/runtime.ts +154 -38
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
202
|
-
*
|
|
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
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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>
|
|
258
|
-
* dry-evaluates the rules against a hypothetical call
|
|
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
|
-
|
|
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
|
+
}
|