dsh-permission-rules 0.4.2 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.es.md +160 -117
  3. package/README.hi.md +162 -119
  4. package/README.md +133 -90
  5. package/README.pt.md +161 -118
  6. package/README.zh.md +162 -119
  7. package/docs/rule-test-vectors/README.md +28 -0
  8. package/docs/rule-test-vectors/corpus.json +109 -0
  9. package/lib/client.js +4693 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1530 -179
  12. package/lib/typert.host.js +4305 -0
  13. package/lib/types/client/index.d.ts +28 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/locales.d.ts +9 -0
  16. package/lib/types/client/locales.d.ts.map +1 -0
  17. package/lib/types/client/remote.d.ts +181 -0
  18. package/lib/types/client/remote.d.ts.map +1 -0
  19. package/lib/types/client/section.d.ts +35 -0
  20. package/lib/types/client/section.d.ts.map +1 -0
  21. package/lib/types/config.d.ts +54 -2
  22. package/lib/types/config.d.ts.map +1 -1
  23. package/lib/types/events.d.ts +45 -0
  24. package/lib/types/events.d.ts.map +1 -1
  25. package/lib/types/index.d.ts +8 -6
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/network.d.ts +90 -0
  28. package/lib/types/network.d.ts.map +1 -0
  29. package/lib/types/prose.d.ts +15 -0
  30. package/lib/types/prose.d.ts.map +1 -1
  31. package/lib/types/proxy.d.ts +128 -0
  32. package/lib/types/proxy.d.ts.map +1 -0
  33. package/lib/types/remote-service.d.ts +45 -0
  34. package/lib/types/remote-service.d.ts.map +1 -0
  35. package/lib/types/rules.d.ts +131 -0
  36. package/lib/types/rules.d.ts.map +1 -1
  37. package/lib/types/runtime.d.ts +153 -21
  38. package/lib/types/runtime.d.ts.map +1 -1
  39. package/lib/types/settings.d.ts +28 -0
  40. package/lib/types/settings.d.ts.map +1 -0
  41. package/lib/types/typert.host.d.ts +166 -0
  42. package/lib/types/typert.host.d.ts.map +1 -0
  43. package/lib/types/wire.d.ts +421 -0
  44. package/lib/types/wire.d.ts.map +1 -0
  45. package/package.json +182 -144
  46. package/scripts/repair-session-logs.mjs +7 -2
  47. package/src/client/index.ts +111 -0
  48. package/src/client/locales.ts +94 -0
  49. package/src/client/remote.ts +41 -0
  50. package/src/client/section.ts +201 -0
  51. package/src/config.ts +111 -0
  52. package/src/events.ts +46 -0
  53. package/src/index.ts +2 -0
  54. package/src/network.ts +177 -0
  55. package/src/prose.ts +85 -10
  56. package/src/proxy.ts +327 -0
  57. package/src/remote-service.ts +109 -0
  58. package/src/rules.ts +424 -5
  59. package/src/runtime.ts +464 -53
  60. package/src/settings.ts +74 -0
  61. package/src/typert.host.ts +25 -0
  62. package/src/wire.ts +239 -0
  63. package/lib/types/config.js +0 -99
  64. package/lib/types/config.js.map +0 -1
  65. package/lib/types/events.js +0 -44
  66. package/lib/types/events.js.map +0 -1
  67. package/lib/types/glob.js +0 -307
  68. package/lib/types/glob.js.map +0 -1
  69. package/lib/types/index.js +0 -27
  70. package/lib/types/index.js.map +0 -1
  71. package/lib/types/prose.js +0 -173
  72. package/lib/types/prose.js.map +0 -1
  73. package/lib/types/rules.js +0 -610
  74. package/lib/types/rules.js.map +0 -1
  75. package/lib/types/runtime.js +0 -818
  76. package/lib/types/runtime.js.map +0 -1
package/src/prose.ts CHANGED
@@ -58,6 +58,16 @@ export interface UiProse {
58
58
  unreachableWarning: (numbers: readonly number[]) => string
59
59
  /** Empty-source placeholder in the rules header. */
60
60
  emptySource: string
61
+ /** Line shown when the network policy is disabled (`network.enabled: false`). */
62
+ networkDisabled: string
63
+ /** Header naming the network mode, its sandbox preset, and proxy liveness. */
64
+ networkHeader: (mode: string, sandboxMode: string | undefined, configuredMode: string, proxyActive: boolean, proxyPort: number) => string
65
+ /** Cumulative block counters line. */
66
+ networkCounters: (denied: number, askBlocked: number) => string
67
+ /** Line shown when no proxy blocks were recorded yet. */
68
+ noNetworkBlocks: string
69
+ /** One recent proxy-block row. */
70
+ networkBlockLine: (time: number, tool: string, attributed: boolean, domain: string, scheme: string | undefined, port: number | undefined, action: string, matched: boolean, ruleIndex: number | undefined, reason: string | undefined) => string
61
71
  }
62
72
 
63
73
  /** Tokens `describeRule` uses for its match-dimension prefixes. */
@@ -70,6 +80,11 @@ export interface DescribeTokens {
70
80
  absent: string
71
81
  when: string
72
82
  platform: string
83
+ network: string
84
+ domains: string
85
+ ips: string
86
+ ports: string
87
+ schemes: string
73
88
  disabled: string
74
89
  tags: string
75
90
  /** Prefix of the per-rule source-file attribution. */
@@ -90,7 +105,7 @@ const EN: UiProse = {
90
105
  lastReloadWarning: error => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
91
106
  dryRunNotice: 'Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.',
92
107
  unknownArg: arg => `Unknown /rules argument "${arg}". ${EN.usage}`,
93
- usage: 'Usage: /rules [list | reload | decisions [n] | test <tool> <json-args>]',
108
+ usage: 'Usage: /rules [list | reload | network | decisions [n] | test <tool> <json-args>]',
94
109
  decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
95
110
  noDecisions: 'No permission decisions recorded in this session yet.',
96
111
  auditDisabledNotice: 'Session-log audit is disabled on this host: its Session.append predates the ignorable marker and would make sessions unresumable elsewhere (set allowUnmarkedAudit: true to opt back in).',
@@ -110,6 +125,18 @@ const EN: UiProse = {
110
125
  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"}',
111
126
  unreachableWarning: numbers => `Warning: rule${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')} ${numbers.length > 1 ? 'are' : 'is'} unreachable (shadowed by an earlier catch-all rule).`,
112
127
  emptySource: '(no rule file — empty rule set)',
128
+ networkDisabled: 'Network policy disabled (network.enabled: false): no proxy, no web-tool mode defaults.',
129
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Network policy: mode ${mode}${sandboxMode === undefined ? '' : ` (sandbox preset ${sandboxMode})`}${configuredMode === 'auto' ? '' : ` (configured ${configuredMode})`}; proxy ${proxyActive ? `active on 127.0.0.1:${proxyPort}` : 'INACTIVE (bind failed — shell network policy is not enforced)'}.`,
130
+ networkCounters: (denied, askBlocked) => `Blocks: ${denied} denied, ${askBlocked} ask-blocked.`,
131
+ noNetworkBlocks: 'No network blocks recorded yet.',
132
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
133
+ const when = new Date(time).toISOString()
134
+ const who = `${tool}${attributed ? '' : ' (unattributed)'}`
135
+ const target = `${scheme ?? '?'}://${domain}${port === undefined ? '' : `:${port}`}`
136
+ const by = matched ? (ruleIndex === undefined ? '' : ` (rule ${ruleIndex + 1})`) : ' (mode default)'
137
+ const why = reason === undefined ? '' : `: ${short(reason)}`
138
+ return `- ${when} ${action} ${who} → ${target}${by}${why}`
139
+ },
113
140
  }
114
141
 
115
142
  const ZH: UiProse = {
@@ -121,7 +148,7 @@ const ZH: UiProse = {
121
148
  lastReloadWarning: error => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
122
149
  dryRunNotice: '干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。',
123
150
  unknownArg: arg => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
124
- usage: '用法:/rules [list | reload | decisions [n] | test <工具名> <json-参数>]',
151
+ usage: '用法:/rules [list | reload | network | decisions [n] | test <工具名> <json-参数>]',
125
152
  decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
126
153
  noDecisions: '本会话还没有记录任何权限裁决。',
127
154
  auditDisabledNotice: '本宿主上会话日志审计已停用:其 Session.append 早于 ignorable 标记支持,会令会话在其他宿主上无法恢复(设 allowUnmarkedAudit: true 可重新开启)。',
@@ -141,6 +168,18 @@ const ZH: UiProse = {
141
168
  testUsage: '用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... [--platform <平台名>] <工具名> <json-参数>,例如 /rules test bash {"command":"git push origin main"}',
142
169
  unreachableWarning: numbers => `警告:规则 ${numbers.join('、')} 不可达(被前面的通配规则遮蔽)。`,
143
170
  emptySource: '(无规则文件——空规则集)',
171
+ networkDisabled: '网络策略已停用(network.enabled: false):无代理、无 web 工具模式默认裁决。',
172
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `网络策略:模式 ${mode}${sandboxMode === undefined ? '' : `(沙箱预设 ${sandboxMode})`}${configuredMode === 'auto' ? '' : `(显式配置 ${configuredMode})`};代理${proxyActive ? `运行于 127.0.0.1:${proxyPort}` : '未激活(绑定失败——shell 网络策略未生效)'}。`,
173
+ networkCounters: (denied, askBlocked) => `拦截:拒绝 ${denied} 次,待审批阻断 ${askBlocked} 次。`,
174
+ noNetworkBlocks: '尚未记录任何网络拦截。',
175
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
176
+ const when = new Date(time).toISOString()
177
+ const who = `${tool}${attributed ? '' : '(未归属)'}`
178
+ const target = `${scheme ?? '?'}://${domain}${port === undefined ? '' : `:${port}`}`
179
+ const by = matched ? (ruleIndex === undefined ? '' : `(规则 ${ruleIndex + 1})`) : '(模式默认)'
180
+ const why = reason === undefined ? '' : `:${short(reason)}`
181
+ return `- ${when} ${action} ${who} → ${target}${by}${why}`
182
+ },
144
183
  }
145
184
 
146
185
  const ES: UiProse = {
@@ -152,7 +191,7 @@ const ES: UiProse = {
152
191
  lastReloadWarning: error => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
153
192
  dryRunNotice: 'Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.',
154
193
  unknownArg: arg => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
155
- usage: 'Uso: /rules [list | reload | decisions [n] | test <herramienta> <json-args>]',
194
+ usage: 'Uso: /rules [list | reload | network | decisions [n] | test <herramienta> <json-args>]',
156
195
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
157
196
  noDecisions: 'Esta sesión aún no registra decisiones de permiso.',
158
197
  auditDisabledNotice: 'La auditoría del registro de sesión está desactivada en este host: su Session.append es anterior al marcador ignorable y haría las sesiones irrecuperables en otros hosts (establece allowUnmarkedAudit: true para reactivarla).',
@@ -172,6 +211,18 @@ const ES: UiProse = {
172
211
  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"}',
173
212
  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).`,
174
213
  emptySource: '(sin archivo de reglas — conjunto vacío)',
214
+ networkDisabled: 'Política de red desactivada (network.enabled: false): sin proxy, sin valores predeterminados de modo para herramientas web.',
215
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de red: modo ${mode}${sandboxMode === undefined ? '' : ` (preset sandbox ${sandboxMode})`}${configuredMode === 'auto' ? '' : ` (configurado ${configuredMode})`}; proxy ${proxyActive ? `activo en 127.0.0.1:${proxyPort}` : 'INACTIVO (fallo de bind — la política de red de shell no se aplica)'}.`,
216
+ networkCounters: (denied, askBlocked) => `Bloqueos: ${denied} denegados, ${askBlocked} bloqueados por aprobación.`,
217
+ noNetworkBlocks: 'Aún no se han registrado bloqueos de red.',
218
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
219
+ const when = new Date(time).toISOString()
220
+ const who = `${tool}${attributed ? '' : ' (sin atribuir)'}`
221
+ const target = `${scheme ?? '?'}://${domain}${port === undefined ? '' : `:${port}`}`
222
+ const by = matched ? (ruleIndex === undefined ? '' : ` (regla ${ruleIndex + 1})`) : ' (modo predeterminado)'
223
+ const why = reason === undefined ? '' : `: ${short(reason)}`
224
+ return `- ${when} ${action} ${who} → ${target}${by}${why}`
225
+ },
175
226
  }
176
227
 
177
228
  const PT: UiProse = {
@@ -183,7 +234,7 @@ const PT: UiProse = {
183
234
  lastReloadWarning: error => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
184
235
  dryRunNotice: 'Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.',
185
236
  unknownArg: arg => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
186
- usage: 'Uso: /rules [list | reload | decisions [n] | test <ferramenta> <json-args>]',
237
+ usage: 'Uso: /rules [list | reload | network | decisions [n] | test <ferramenta> <json-args>]',
187
238
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
188
239
  noDecisions: 'Esta sessão ainda não registrou decisões de permissão.',
189
240
  auditDisabledNotice: 'A auditoria do registro da sessão está desativada neste host: seu Session.append é anterior ao marcador ignorable e tornaria as sessões irrecuperáveis em outros hosts (defina allowUnmarkedAudit: true para reativar).',
@@ -203,6 +254,18 @@ const PT: UiProse = {
203
254
  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"}',
204
255
  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).`,
205
256
  emptySource: '(sem arquivo de regras — conjunto vazio)',
257
+ networkDisabled: 'Política de rede desativada (network.enabled: false): sem proxy, sem padrões de modo para ferramentas web.',
258
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de rede: modo ${mode}${sandboxMode === undefined ? '' : ` (preset sandbox ${sandboxMode})`}${configuredMode === 'auto' ? '' : ` (configurado ${configuredMode})`}; proxy ${proxyActive ? `ativo em 127.0.0.1:${proxyPort}` : 'INATIVO (falha de bind — a política de rede do shell não está ativa)'}.`,
259
+ networkCounters: (denied, askBlocked) => `Bloqueios: ${denied} negados, ${askBlocked} bloqueados por aprovação.`,
260
+ noNetworkBlocks: 'Nenhum bloqueio de rede registrado ainda.',
261
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
262
+ const when = new Date(time).toISOString()
263
+ const who = `${tool}${attributed ? '' : ' (não atribuído)'}`
264
+ const target = `${scheme ?? '?'}://${domain}${port === undefined ? '' : `:${port}`}`
265
+ const by = matched ? (ruleIndex === undefined ? '' : ` (regra ${ruleIndex + 1})`) : ' (modo padrão)'
266
+ const why = reason === undefined ? '' : `: ${short(reason)}`
267
+ return `- ${when} ${action} ${who} → ${target}${by}${why}`
268
+ },
206
269
  }
207
270
 
208
271
  const HI: UiProse = {
@@ -214,7 +277,7 @@ const HI: UiProse = {
214
277
  lastReloadWarning: error => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
215
278
  dryRunNotice: 'ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।',
216
279
  unknownArg: arg => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
217
- usage: 'उपयोग: /rules [list | reload | decisions [n] | test <टूल> <json-args>]',
280
+ usage: 'उपयोग: /rules [list | reload | network | decisions [n] | test <टूल> <json-args>]',
218
281
  decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
219
282
  noDecisions: 'इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।',
220
283
  auditDisabledNotice: 'इस होस्ट पर सत्र-लॉग ऑडिट अक्षम है: इसका Session.append ignorable मार्कर से पुराना है और सत्र अन्य होस्ट पर अप्राप्य बन जाते (पुनः चालू करने के लिए allowUnmarkedAudit: true सेट करें)।',
@@ -234,6 +297,18 @@ const HI: UiProse = {
234
297
  testUsage: 'उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... [--platform <नाम>] <टूल> <json-args>, जैसे /rules test bash {"command":"git push origin main"}',
235
298
  unreachableWarning: numbers => `चेतावनी: नियम ${numbers.join(', ')} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
236
299
  emptySource: '(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)',
300
+ networkDisabled: 'नेटवर्क नीति अक्षम (network.enabled: false): कोई प्रॉक्सी नहीं, कोई वेब-टूल मोड डिफ़ॉल्ट नहीं।',
301
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `नेटवर्क नीति: मोड ${mode}${sandboxMode === undefined ? '' : ` (सैंडबॉक्स प्रीसेट ${sandboxMode})`}${configuredMode === 'auto' ? '' : ` (कॉन्फ़िगर किया ${configuredMode})`}; प्रॉक्सी ${proxyActive ? `सक्रिय 127.0.0.1:${proxyPort} पर` : 'निष्क्रिय (बाइंड विफल — शेल नेटवर्क नीति लागू नहीं)'}।`,
302
+ networkCounters: (denied, askBlocked) => `ब्लॉक: ${denied} अस्वीकृत, ${askBlocked} अनुमोदन-ब्लॉक।`,
303
+ noNetworkBlocks: 'अभी तक कोई नेटवर्क ब्लॉक दर्ज नहीं हुआ।',
304
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
305
+ const when = new Date(time).toISOString()
306
+ const who = `${tool}${attributed ? '' : ' (असाइन नहीं)'}`
307
+ const target = `${scheme ?? '?'}://${domain}${port === undefined ? '' : `:${port}`}`
308
+ const by = matched ? (ruleIndex === undefined ? '' : ` (नियम ${ruleIndex + 1})`) : ' (मोड डिफ़ॉल्ट)'
309
+ const why = reason === undefined ? '' : `: ${short(reason)}`
310
+ return `- ${when} ${action} ${who} → ${target}${by}${why}`
311
+ },
237
312
  }
238
313
 
239
314
  /** The localized prose tables by language. */
@@ -241,9 +316,9 @@ export const UI_PROSE: Readonly<Record<UiLanguage, UiProse>> = { en: EN, zh: ZH,
241
316
 
242
317
  /** `describeRule` match-dimension tokens by language. */
243
318
  export const DESCRIBE_TOKENS: Readonly<Record<UiLanguage, DescribeTokens>> = {
244
- en: { allTools: 'all tools', tools: 'tools', agents: 'agents', params: 'params', paths: 'paths', absent: 'absent', when: 'when', platform: 'platform', disabled: 'disabled', tags: 'tags', src: 'src' },
245
- zh: { allTools: '全部工具', tools: '工具', agents: '代理', params: '参数', paths: '路径', absent: '缺省键', when: '条件', platform: '平台', disabled: '已禁用', tags: '标签', src: '来源' },
246
- 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' },
247
- 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' },
248
- hi: { allTools: 'सभी टूल', tools: 'टूल', agents: 'एजेंट', params: 'पैरामीटर', paths: 'पथ', absent: 'अनुपस्थित', when: 'शर्त', platform: 'प्लेटफ़ॉर्म', disabled: 'अक्षम', tags: 'टैग', src: 'स्रोत' },
319
+ en: { allTools: 'all tools', tools: 'tools', agents: 'agents', params: 'params', paths: 'paths', absent: 'absent', when: 'when', platform: 'platform', network: 'network', domains: 'domains', ips: 'ips', ports: 'ports', schemes: 'schemes', disabled: 'disabled', tags: 'tags', src: 'src' },
320
+ zh: { allTools: '全部工具', tools: '工具', agents: '代理', params: '参数', paths: '路径', absent: '缺省键', when: '条件', platform: '平台', network: '网络', domains: '域名', ips: 'IP', ports: '端口', schemes: '协议', disabled: '已禁用', tags: '标签', src: '来源' },
321
+ es: { allTools: 'todas las herramientas', tools: 'herramientas', agents: 'agentes', params: 'parámetros', paths: 'rutas', absent: 'ausentes', when: 'cuándo', platform: 'plataforma', network: 'red', domains: 'dominios', ips: 'IP', ports: 'puertos', schemes: 'esquemas', disabled: 'deshabilitada', tags: 'etiquetas', src: 'origen' },
322
+ pt: { allTools: 'todas as ferramentas', tools: 'ferramentas', agents: 'agentes', params: 'parâmetros', paths: 'caminhos', absent: 'ausentes', when: 'quando', platform: 'plataforma', network: 'rede', domains: 'domínios', ips: 'IP', ports: 'portas', schemes: 'esquemas', disabled: 'desativada', tags: 'etiquetas', src: 'origem' },
323
+ hi: { allTools: 'सभी टूल', tools: 'टूल', agents: 'एजेंट', params: 'पैरामीटर', paths: 'पथ', absent: 'अनुपस्थित', when: 'शर्त', platform: 'प्लेटफ़ॉर्म', network: 'नेटवर्क', domains: 'डोमेन', ips: 'IP', ports: 'पोर्ट', schemes: 'स्कीम', disabled: 'अक्षम', tags: 'टैग', src: 'स्रोत' },
249
324
  }
package/src/proxy.ts ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The plugin's built-in local HTTP/CONNECT proxy: the process-level
3
+ * network gate for shell subprocess traffic. Bash/pwsh children inherit
4
+ * `HTTP(S)_PROXY`/`ALL_PROXY` environment variables pointing here, and the
5
+ * proxy adjudicates EVERY connection target against the loaded network
6
+ * rules and the active policy mode — the domain decision lives in the
7
+ * proxy layer, not in per-command wrappers.
8
+ *
9
+ * Boundaries (documented in the README): DSH's official `ctx.sandbox`
10
+ * enforces FILE effects only, so network interception is entirely this
11
+ * plugin's job; the proxy has no session context, so an `ask` decision at
12
+ * the proxy layer cannot ride the interactive approval seam — it blocks
13
+ * the connection with a structured message and is audit-logged instead
14
+ * (web tools get the real approval seam at `tools/pre-execute`).
15
+ * @module dsh-permission-rules/proxy
16
+ */
17
+
18
+ import { createServer, request as httpRequest } from 'node:http'
19
+ import { request as httpsRequest } from 'node:https'
20
+ import { connect } from 'node:net'
21
+ import { lookup } from 'node:dns/promises'
22
+ import type { AddressInfo } from 'node:net'
23
+ import type { Duplex } from 'node:stream'
24
+ import type { Server, IncomingMessage, ServerResponse } from 'node:http'
25
+ import type { CallId } from '@deepseek-ai/dsh-llm'
26
+ import type { NetworkDecision, NetworkMode } from './network.ts'
27
+ import { blockMessage } from './network.ts'
28
+ import { isIpLiteral, parseUrlTarget } from './rules.ts'
29
+ import type { NetworkTarget, SchemeName } from './rules.ts'
30
+
31
+ /** Proxy environment variable names the injector sets/restores (uppercase + lowercase for mixed-ecosystem CLIs). */
32
+ export const PROXY_ENV_NAMES: readonly string[] = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy']
33
+
34
+ /** NO_PROXY names cleared (or preserved) by the injector. */
35
+ export const NO_PROXY_ENV_NAMES: readonly string[] = ['NO_PROXY', 'no_proxy']
36
+
37
+ /** One recorded proxy-layer block (settings-page list + session audit + logger). */
38
+ export interface NetworkBlockRecord {
39
+ readonly time: number
40
+ readonly tool: string
41
+ readonly attributed: boolean
42
+ readonly callId?: CallId
43
+ readonly domain: string
44
+ readonly scheme?: SchemeName
45
+ readonly port?: number
46
+ readonly action: 'deny' | 'ask'
47
+ readonly mode: NetworkMode
48
+ readonly matched: boolean
49
+ readonly source: string
50
+ readonly ruleIndex?: number
51
+ readonly reason?: string
52
+ }
53
+
54
+ /** Cumulative proxy-layer block counters. */
55
+ export interface NetworkStats {
56
+ denied: number
57
+ askBlocked: number
58
+ }
59
+
60
+ /** The attribution the runtime supplies for one connection (the newest in-flight shell execution). */
61
+ export interface ProxyAttribution {
62
+ readonly tool: string
63
+ readonly callId?: CallId
64
+ readonly agent?: {
65
+ readonly session: { append(type: 'permissionRules/network', data: unknown, options?: { ignorable?: true }): unknown }
66
+ }
67
+ }
68
+
69
+ /** Construction options for {@link NetworkProxy}. */
70
+ export interface NetworkProxyOptions {
71
+ /** Bind address (loopback by config). */
72
+ readonly bind: string
73
+ /** Requested port; `0` binds an ephemeral port. */
74
+ readonly port: number
75
+ /** Cap on recent-block records kept in memory. */
76
+ readonly maxRecent: number
77
+ /** Decision function supplied by the runtime (rules + mode). */
78
+ readonly decide: (target: NetworkTarget) => NetworkDecision
79
+ /** Current attribution (newest in-flight shell execution), or undefined. */
80
+ readonly attribution?: () => ProxyAttribution | undefined
81
+ /** Called for every blocked connection, after the response is sent. */
82
+ readonly onBlock?: (record: NetworkBlockRecord, attribution: ProxyAttribution | undefined) => void
83
+ /** Logger sink (proxy failures must never crash the host). */
84
+ readonly logger: { warn(message: string): void }
85
+ }
86
+
87
+ /**
88
+ * The local HTTP/CONNECT policy proxy. Binds on demand (the plugin calls
89
+ * {@link start} inside an effect and {@link close} from its disposer);
90
+ * every live tunnel socket is tracked and destroyed on close so updates
91
+ * and uninstalls leave no orphaned connections.
92
+ */
93
+ export class NetworkProxy {
94
+ private server: Server | undefined
95
+ private readonly sockets = new Set<Duplex>()
96
+ private readonly recent: NetworkBlockRecord[] = []
97
+ private readonly stats: NetworkStats = { denied: 0, askBlocked: 0 }
98
+ private actualPort = 0
99
+
100
+ constructor(private readonly options: NetworkProxyOptions) {}
101
+
102
+ /** The bound port (valid after {@link start} resolves). */
103
+ get port(): number {
104
+ return this.actualPort
105
+ }
106
+
107
+ /** Deny/ask blocks recorded since mount, newest first (settings-page list). */
108
+ recentBlocks(): readonly NetworkBlockRecord[] {
109
+ return this.recent
110
+ }
111
+
112
+ /** Cumulative block counters. */
113
+ blockStats(): NetworkStats {
114
+ return { ...this.stats }
115
+ }
116
+
117
+ /** Bind the server and return the actual port. */
118
+ start(): Promise<number> {
119
+ const server = createServer((req, res) => {
120
+ void this.handleRequest(req, res)
121
+ })
122
+ server.on('connect', (req, socket, head) => {
123
+ void this.handleConnect(req, socket, head)
124
+ })
125
+ server.on('error', (error: unknown) => {
126
+ this.options.logger.warn(`permission-rules: proxy server error: ${String(error)}`)
127
+ })
128
+ this.server = server
129
+ return new Promise((resolve, reject) => {
130
+ const onError = (error: Error): void => {
131
+ server.off('listening', onListening)
132
+ reject(error)
133
+ }
134
+ const onListening = (): void => {
135
+ server.off('error', onError)
136
+ const address = server.address() as AddressInfo
137
+ this.actualPort = address.port
138
+ resolve(address.port)
139
+ }
140
+ server.once('error', onError)
141
+ server.once('listening', onListening)
142
+ server.listen(this.options.port, this.options.bind)
143
+ })
144
+ }
145
+
146
+ /** Stop the server and destroy every tunnel; resolves when sockets are closed. */
147
+ close(): Promise<void> {
148
+ const server = this.server
149
+ this.server = undefined
150
+ for (const socket of this.sockets) socket.destroy()
151
+ this.sockets.clear()
152
+ if (server === undefined) return Promise.resolve()
153
+ return new Promise(resolve => {
154
+ server.close(() => resolve())
155
+ })
156
+ }
157
+
158
+ /** Plain-HTTP proxying: absolute-form requests are adjudicated and forwarded (or blocked with a structured 403). */
159
+ private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
160
+ const target = parseUrlTarget(req.url ?? '')
161
+ if (target === undefined || target.scheme === undefined) {
162
+ res.writeHead(404, { 'content-type': 'text/plain' })
163
+ res.end('permission-rules proxy: only absolute-form proxy requests are served here\n')
164
+ return
165
+ }
166
+ await this.forwardOrBlock(res, target, () => {
167
+ const upstream = new URL(req.url as string)
168
+ const send = upstream.protocol === 'https:' ? httpsRequest : httpRequest
169
+ const proxyReq = send(upstream, { method: req.method, headers: req.headers }, proxyRes => {
170
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
171
+ proxyRes.pipe(res)
172
+ })
173
+ proxyReq.on('error', (error: unknown) => {
174
+ if (!res.headersSent) {
175
+ res.writeHead(502, { 'content-type': 'text/plain' })
176
+ res.end(`[network: upstream error] ${String(error)}\n`)
177
+ } else {
178
+ res.destroy()
179
+ }
180
+ })
181
+ req.pipe(proxyReq)
182
+ })
183
+ }
184
+
185
+ /** CONNECT tunneling (HTTPS and friends): adjudicate, then either 403 or an established TCP tunnel. */
186
+ private async handleConnect(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
187
+ const target = connectTarget(req.url ?? '')
188
+ if (target === undefined) {
189
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
190
+ return
191
+ }
192
+ const decision = await this.decideWithResolution(target)
193
+ if (decision.action !== 'allow') {
194
+ this.recordBlock(decision, target)
195
+ const body = blockMessage(decision)
196
+ socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
197
+ return
198
+ }
199
+ const upstream = connect(target.port ?? 443, target.host)
200
+ this.sockets.add(socket)
201
+ this.sockets.add(upstream)
202
+ const cleanup = (): void => {
203
+ this.sockets.delete(socket)
204
+ this.sockets.delete(upstream)
205
+ }
206
+ socket.on('close', cleanup)
207
+ upstream.on('close', cleanup)
208
+ upstream.on('error', () => socket.destroy())
209
+ socket.on('error', () => upstream.destroy())
210
+ upstream.once('connect', () => {
211
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
212
+ if (head.length > 0) upstream.write(head)
213
+ upstream.pipe(socket)
214
+ socket.pipe(upstream)
215
+ })
216
+ }
217
+
218
+ /** Adjudicate one plain-HTTP request; deny/ask blocks with a structured 403 before any forwarding. */
219
+ private async forwardOrBlock(res: ServerResponse, target: NetworkTarget, forward: () => void): Promise<void> {
220
+ const decision = await this.decideWithResolution(target)
221
+ if (decision.action !== 'allow') {
222
+ this.recordBlock(decision, target)
223
+ const body = blockMessage(decision)
224
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) })
225
+ res.end(body)
226
+ return
227
+ }
228
+ forward()
229
+ }
230
+
231
+ /** Resolve a hostname once so `ips`-scoped rules see the real addresses, then decide. */
232
+ private async decideWithResolution(target: NetworkTarget): Promise<NetworkDecision> {
233
+ if (!isIpLiteral(target.host)) {
234
+ try {
235
+ const addresses = await lookup(target.host, { all: true, verbatim: true })
236
+ const resolved = addresses.map(entry => entry.address)
237
+ if (resolved.length > 0) return this.options.decide({ ...target, ips: [...target.ips, ...resolved] })
238
+ } catch {
239
+ // Unresolvable target: decide on the literal name (ip-scoped rules simply cannot fire).
240
+ }
241
+ }
242
+ return this.options.decide(target)
243
+ }
244
+
245
+ /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
246
+ private recordBlock(decision: NetworkDecision, target: NetworkTarget): void {
247
+ const attribution = this.options.attribution?.()
248
+ const record: NetworkBlockRecord = {
249
+ time: Date.now(),
250
+ tool: attribution?.tool ?? 'subprocess',
251
+ attributed: attribution !== undefined,
252
+ ...(attribution?.callId !== undefined ? { callId: attribution.callId } : {}),
253
+ domain: target.host,
254
+ ...(target.scheme !== undefined ? { scheme: target.scheme } : {}),
255
+ ...(target.port !== undefined ? { port: target.port } : {}),
256
+ action: decision.action === 'ask' ? 'ask' : 'deny',
257
+ mode: decision.mode,
258
+ matched: decision.matched,
259
+ source: decision.source ?? '',
260
+ ...(decision.ruleIndex !== undefined ? { ruleIndex: decision.ruleIndex } : {}),
261
+ ...(decision.rule !== undefined ? { reason: decision.rule.reason } : {}),
262
+ }
263
+ if (record.action === 'deny') this.stats.denied += 1
264
+ else this.stats.askBlocked += 1
265
+ this.recent.unshift(record)
266
+ if (this.recent.length > this.options.maxRecent) this.recent.length = this.options.maxRecent
267
+ this.options.logger.warn(`permission-rules: network ${record.action === 'deny' ? 'denied' : 'ask-blocked'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} (mode ${decision.mode}${decision.matched ? `, rule ${(decision.ruleIndex ?? 0) + 1}` : ', mode default'})`)
268
+ try {
269
+ this.options.onBlock?.(record, attribution)
270
+ } catch (error: unknown) {
271
+ this.options.logger.warn(`permission-rules: network block hook failed: ${String(error)}`)
272
+ }
273
+ }
274
+ }
275
+
276
+ /** Parse a CONNECT authority (`host:port`) into an https target with an explicit port. */
277
+ function connectTarget(authority: string): NetworkTarget | undefined {
278
+ const colon = authority.lastIndexOf(':')
279
+ if (colon <= 0) return undefined
280
+ const host = authority.slice(0, colon).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '')
281
+ const port = Number(authority.slice(colon + 1))
282
+ if (host.length === 0 || !Number.isInteger(port) || port < 1 || port > 65535) return undefined
283
+ return { scheme: 'https', host, port, ips: isIpLiteral(host) ? [host] : [] }
284
+ }
285
+
286
+ /**
287
+ * Inject the proxy environment variables for subprocesses and return a
288
+ * disposer restoring every previous value exactly (so updates and
289
+ * uninstalls leave the host environment untouched). `noProxy: 'clear'`
290
+ * empties NO_PROXY so the policy cannot be bypassed through ambient
291
+ * exclusions; `preserve` keeps ambient values.
292
+ *
293
+ * The snapshot pass covers EVERY name BEFORE any write: on Windows
294
+ * `process.env` is case-insensitive, so the uppercase/lowercase spellings
295
+ * share one variable — writing `HTTP_PROXY` mid-loop would otherwise
296
+ * poison the later snapshot of `http_proxy` and make the restore write
297
+ * the proxy address back over the original.
298
+ * @param port - the bound proxy port.
299
+ * @param noProxy - the configured NO_PROXY handling.
300
+ * @returns the restore disposer.
301
+ */
302
+ export function injectProxyEnv(port: number, noProxy: 'clear' | 'preserve'): () => void {
303
+ const previous = new Map<string, string | undefined>()
304
+ const value = `http://127.0.0.1:${port}`
305
+ for (const name of PROXY_ENV_NAMES) previous.set(name, process.env[name])
306
+ if (noProxy === 'clear') {
307
+ for (const name of NO_PROXY_ENV_NAMES) previous.set(name, process.env[name])
308
+ }
309
+ for (const name of PROXY_ENV_NAMES) process.env[name] = value
310
+ if (noProxy === 'clear') {
311
+ for (const name of NO_PROXY_ENV_NAMES) process.env[name] = ''
312
+ }
313
+ return () => {
314
+ for (const name of PROXY_ENV_NAMES) {
315
+ const old = previous.get(name)
316
+ if (old === undefined) delete process.env[name]
317
+ else process.env[name] = old
318
+ }
319
+ if (noProxy === 'clear') {
320
+ for (const name of NO_PROXY_ENV_NAMES) {
321
+ const old = previous.get(name)
322
+ if (old === undefined) delete process.env[name]
323
+ else process.env[name] = old
324
+ }
325
+ }
326
+ }
327
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The settings-page Remote service: exposes the `permissionRules` Typert
3
+ * namespace (network snapshot + rule-file read/save/reload) to the
4
+ * browser half. All state lives in the {@link PermissionRulesRuntime}
5
+ * this service delegates to; the service itself only bridges the wire
6
+ * vocabulary. The rule editor only ever touches KNOWN rule sources —
7
+ * arbitrary paths are refused by the runtime.
8
+ * @module dsh-permission-rules/remote-service
9
+ */
10
+
11
+ import { existsSync } from 'node:fs'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
14
+ import type { PermissionRulesRuntime } from './runtime.ts'
15
+ import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from './wire.ts'
16
+
17
+ declare module '@deepseek-ai/cordis' {
18
+ interface Context {
19
+ /** Settings-page snapshot + rule-editor bridge (this package). */
20
+ permissionRules: PermissionRulesRemoteService
21
+ }
22
+ }
23
+
24
+ /** Service config: the runtime instance whose state the wire exposes. */
25
+ export interface PermissionRulesRemoteServiceConfig {
26
+ /** The runtime holding the loaded rules, proxy state, and block records. */
27
+ runtime: PermissionRulesRuntime
28
+ }
29
+
30
+ /**
31
+ * The Typert Remote service behind the settings page. Registered through
32
+ * `ctx.plugin(PermissionRulesRemoteService, { runtime })` in
33
+ * {@link apply}; the typert gateway binds its public methods to the
34
+ * invocation descriptors in `./wire.ts`.
35
+ */
36
+ export class PermissionRulesRemoteService extends TypertRemoteService {
37
+ static inject = [] as string[]
38
+
39
+ constructor(ctx: Context, private readonly config: PermissionRulesRemoteServiceConfig) {
40
+ super(ctx, 'permissionRules')
41
+ }
42
+
43
+ /** The settings-page snapshot: mode mapping, proxy liveness, counters, recent blocks, editable rule sources. */
44
+ networkStatus(): PermissionRulesSnapshot {
45
+ const snapshot = this.config.runtime.networkSnapshot()
46
+ const sources = this.config.runtime.knownRuleSources().map(path => {
47
+ const owner = this.config.runtime.sourceOwner(path)
48
+ return { path, exists: existsSync(path), cwd: owner ?? null }
49
+ })
50
+ return {
51
+ enabled: snapshot.enabled,
52
+ mode: snapshot.mode,
53
+ configuredMode: snapshot.configuredMode,
54
+ sandboxMode: snapshot.sandboxMode ?? null,
55
+ proxyPort: snapshot.proxyPort,
56
+ proxyActive: snapshot.proxyActive,
57
+ denied: snapshot.denied,
58
+ askBlocked: snapshot.askBlocked,
59
+ recent: snapshot.recent.map(block => ({
60
+ time: block.time,
61
+ tool: block.tool,
62
+ attributed: block.attributed,
63
+ domain: block.domain,
64
+ scheme: block.scheme ?? null,
65
+ port: block.port ?? null,
66
+ action: block.action,
67
+ mode: block.mode,
68
+ matched: block.matched,
69
+ source: block.source,
70
+ ruleIndex: block.ruleIndex ?? null,
71
+ reason: block.reason ?? null,
72
+ })),
73
+ sources,
74
+ }
75
+ }
76
+
77
+ /** Read one known rule file for the editor. */
78
+ rulesRead(path: string): RulesReadResult {
79
+ const result = this.config.runtime.readRuleFile(path)
80
+ return {
81
+ path: result.path,
82
+ exists: result.exists,
83
+ text: result.text,
84
+ error: result.error ?? null,
85
+ }
86
+ }
87
+
88
+ /** Validate and write one known rule file; invalid edits are rejected before anything touches disk. */
89
+ rulesSave(path: string, text: string): RulesSaveResult {
90
+ const result = this.config.runtime.saveRuleFile(path, text)
91
+ return {
92
+ ok: result.ok,
93
+ error: result.error ?? null,
94
+ reloaded: result.reloaded ?? null,
95
+ }
96
+ }
97
+
98
+ /** Re-read every cached workspace chain (after an external edit). */
99
+ reload(): RulesReloadResult {
100
+ try {
101
+ this.config.runtime.reloadAll()
102
+ return { ok: true, error: null }
103
+ } catch (error: unknown) {
104
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
105
+ }
106
+ }
107
+ }
108
+
109
+ export default PermissionRulesRemoteService