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.
- package/CHANGELOG.md +34 -0
- package/README.es.md +160 -117
- package/README.hi.md +162 -119
- package/README.md +133 -90
- package/README.pt.md +161 -118
- package/README.zh.md +162 -119
- package/docs/rule-test-vectors/README.md +28 -0
- package/docs/rule-test-vectors/corpus.json +109 -0
- package/lib/client.js +4693 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1530 -179
- package/lib/typert.host.js +4305 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +9 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +181 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/section.d.ts +35 -0
- package/lib/types/client/section.d.ts.map +1 -0
- package/lib/types/config.d.ts +54 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/events.d.ts +45 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/network.d.ts +90 -0
- package/lib/types/network.d.ts.map +1 -0
- package/lib/types/prose.d.ts +15 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +128 -0
- package/lib/types/proxy.d.ts.map +1 -0
- package/lib/types/remote-service.d.ts +45 -0
- package/lib/types/remote-service.d.ts.map +1 -0
- package/lib/types/rules.d.ts +131 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +153 -21
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +166 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +421 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +182 -144
- package/scripts/repair-session-logs.mjs +7 -2
- package/src/client/index.ts +111 -0
- package/src/client/locales.ts +94 -0
- package/src/client/remote.ts +41 -0
- package/src/client/section.ts +201 -0
- package/src/config.ts +111 -0
- package/src/events.ts +46 -0
- package/src/index.ts +2 -0
- package/src/network.ts +177 -0
- package/src/prose.ts +85 -10
- package/src/proxy.ts +327 -0
- package/src/remote-service.ts +109 -0
- package/src/rules.ts +424 -5
- package/src/runtime.ts +464 -53
- package/src/settings.ts +74 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +239 -0
- package/lib/types/config.js +0 -99
- package/lib/types/config.js.map +0 -1
- package/lib/types/events.js +0 -44
- package/lib/types/events.js.map +0 -1
- package/lib/types/glob.js +0 -307
- package/lib/types/glob.js.map +0 -1
- package/lib/types/index.js +0 -27
- package/lib/types/index.js.map +0 -1
- package/lib/types/prose.js +0 -173
- package/lib/types/prose.js.map +0 -1
- package/lib/types/rules.js +0 -610
- package/lib/types/rules.js.map +0 -1
- package/lib/types/runtime.js +0 -818
- 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
|