@shendeguize/dsh-agent-sidecar 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
package/src/guard.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Self-contained request guard for the plugin's self-registered routes.
3
+ *
4
+ * dsh's webServer has no authentication layer and its `/api` trust fence
5
+ * (connection package) does not cover plugin-registered routes, so every
6
+ * route this plugin opens must carry its own guard (design doc §4.f / §8).
7
+ *
8
+ * The five layers defend against *browser-mediated* attacks (CSRF, DNS
9
+ * rebinding, cross-site requests). They deliberately do NOT claim to stop a
10
+ * local process that opens its own TCP connection to loopback — that is the
11
+ * structural trust posture of the unauthenticated dsh webServer (ADR-8).
12
+ *
13
+ * This module is pure `node:http` types on purpose: no cordis/dsh imports,
14
+ * so it stays unit-testable with plain mock objects.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type { IncomingMessage } from 'node:http'
20
+
21
+ /** Dynamic knobs consulted by the write-action gate (layer 5). */
22
+ export interface GuardOptions {
23
+ /**
24
+ * Read the `inject.enabled` setting at call time (live setting — must not
25
+ * be snapshotted at plugin startup).
26
+ */
27
+ allowWriteActions(): boolean
28
+ }
29
+
30
+ /** Outcome of a guard evaluation; `status`/`reason` map onto the HTTP reply. */
31
+ export type GuardVerdict =
32
+ | { ok: true }
33
+ | { ok: false; status: number; reason: string }
34
+
35
+ /** The minimal request surface the guard reads; mock-friendly for tests. */
36
+ export type GuardableRequest = Pick<
37
+ IncomingMessage,
38
+ 'method' | 'headers' | 'socket' | 'url'
39
+ >
40
+
41
+ const OK: GuardVerdict = { ok: true }
42
+
43
+ const forbid = (reason: string): GuardVerdict => ({ ok: false, status: 403, reason })
44
+
45
+ /** Methods whose body is a state-changing payload (layer 4 media-type gate). */
46
+ const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH'])
47
+
48
+ /**
49
+ * True when `addr` (a `socket.remoteAddress` value) is a loopback address:
50
+ * IPv4 `127.0.0.0/8`, IPv6 `::1`, or the IPv4-mapped form `::ffff:127.x.y.z`
51
+ * that Node reports on dual-stack listeners. Anything unparsable is `false`
52
+ * (fail closed).
53
+ */
54
+ export function isLoopbackAddress(addr: string | undefined): boolean {
55
+ if (!addr) return false
56
+ let candidate = addr.trim().toLowerCase()
57
+ if (candidate.startsWith('::ffff:')) candidate = candidate.slice('::ffff:'.length)
58
+ if (candidate === '::1') return true
59
+ return isLoopbackIpv4(candidate)
60
+ }
61
+
62
+ /** Strict dotted-quad check for `127.0.0.0/8`. */
63
+ function isLoopbackIpv4(candidate: string): boolean {
64
+ const parts = candidate.split('.')
65
+ if (parts.length !== 4) return false
66
+ for (const part of parts) {
67
+ if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return false
68
+ }
69
+ return parts[0] === '127'
70
+ }
71
+
72
+ /** Parsed `host[:port]` authority; `host` is lowercased, IPv6 keeps brackets. */
73
+ interface Authority {
74
+ host: string
75
+ /** Explicit port digits, or undefined when the header omitted the port. */
76
+ port: string | undefined
77
+ }
78
+
79
+ /**
80
+ * Parse an authority string (`Host` header shape). Returns undefined for
81
+ * anything malformed: empty, bad brackets, non-numeric or out-of-range port,
82
+ * stray colons. Node keeps only the first `Host` header on duplicates, so a
83
+ * single string is the full input space here.
84
+ */
85
+ function parseAuthority(raw: string | undefined): Authority | undefined {
86
+ if (typeof raw !== 'string') return undefined
87
+ const value = raw.trim().toLowerCase()
88
+ if (!value) return undefined
89
+
90
+ let host: string
91
+ let portPart: string | undefined
92
+ if (value.startsWith('[')) {
93
+ const close = value.indexOf(']')
94
+ if (close <= 1) return undefined
95
+ host = value.slice(0, close + 1)
96
+ const rest = value.slice(close + 1)
97
+ if (rest) {
98
+ if (!rest.startsWith(':')) return undefined
99
+ portPart = rest.slice(1)
100
+ }
101
+ } else {
102
+ const colon = value.indexOf(':')
103
+ if (colon === -1) {
104
+ host = value
105
+ } else {
106
+ host = value.slice(0, colon)
107
+ portPart = value.slice(colon + 1)
108
+ if (portPart.includes(':')) return undefined // unbracketed IPv6 in Host is invalid
109
+ }
110
+ if (!host || /[\s/@#?\\]/.test(host)) return undefined
111
+ }
112
+
113
+ if (portPart !== undefined) {
114
+ if (!/^\d{1,5}$/.test(portPart)) return undefined
115
+ const num = Number(portPart)
116
+ if (num < 1 || num > 65535) return undefined
117
+ }
118
+ return { host, port: portPart }
119
+ }
120
+
121
+ /** True when a parsed authority host names loopback. */
122
+ function authorityIsLoopback(host: string): boolean {
123
+ if (host === 'localhost') return true
124
+ if (host.startsWith('[') && host.endsWith(']')) {
125
+ return isLoopbackAddress(host.slice(1, -1))
126
+ }
127
+ return isLoopbackIpv4(host)
128
+ }
129
+
130
+ /**
131
+ * True when the `Host` header names a loopback authority: `localhost`, an
132
+ * IPv4 `127.0.0.0/8` literal, or a bracketed loopback IPv6 literal — each
133
+ * optionally with a port. Missing/malformed headers are `false` (fail
134
+ * closed; this is the DNS-rebinding gate).
135
+ */
136
+ export function hostIsLoopback(hostHeader: string | undefined): boolean {
137
+ const authority = parseAuthority(hostHeader)
138
+ if (!authority) return false
139
+ return authorityIsLoopback(authority.host)
140
+ }
141
+
142
+ /**
143
+ * Same-origin check between an `Origin` header value and the request's
144
+ * `Host` authority. Scheme may be http or https; host must match exactly
145
+ * (WHATWG-normalized: lowercase, IPv6 canonical bracketed form) and the
146
+ * effective ports must agree. A `Host` without a port accepts either
147
+ * scheme-default origin port (80/443), covering default-port elision.
148
+ */
149
+ function originMatchesAuthority(origin: string, authority: Authority): boolean {
150
+ let url: URL
151
+ try {
152
+ url = new URL(origin)
153
+ } catch {
154
+ return false // includes the opaque `Origin: null`
155
+ }
156
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
157
+
158
+ if (url.hostname.toLowerCase() !== authority.host) return false
159
+
160
+ const originPort = url.port || (url.protocol === 'https:' ? '443' : '80')
161
+ if (authority.port !== undefined) return originPort === authority.port
162
+ return originPort === '80' || originPort === '443'
163
+ }
164
+
165
+ /** Reject when any (possibly `, `-joined multi-value) entry is `cross-site`. */
166
+ function declaresCrossSite(secFetchSite: string | string[] | undefined): boolean {
167
+ if (secFetchSite === undefined) return false
168
+ const values = Array.isArray(secFetchSite) ? secFetchSite : [secFetchSite]
169
+ return values.some((value) =>
170
+ value.split(',').some((entry) => entry.trim().toLowerCase() === 'cross-site'),
171
+ )
172
+ }
173
+
174
+ /** Layers 1-3: remote loopback, Host authority, Origin/sec-fetch-site. */
175
+ function guardReachability(req: {
176
+ headers: IncomingMessage['headers']
177
+ socket: IncomingMessage['socket']
178
+ }): GuardVerdict {
179
+ // Layer 1 — transport: only loopback peers, even if dsh binds 0.0.0.0.
180
+ if (!isLoopbackAddress(req.socket?.remoteAddress ?? undefined)) {
181
+ return forbid('remote_not_loopback')
182
+ }
183
+
184
+ // Layer 2 — Host must be a loopback authority (DNS-rebinding defence).
185
+ const hostHeader = req.headers.host
186
+ const authority =
187
+ typeof hostHeader === 'string' ? parseAuthority(hostHeader) : undefined
188
+ if (!authority || !authorityIsLoopback(authority.host)) {
189
+ return forbid('host_not_loopback')
190
+ }
191
+
192
+ // Layer 3 — Origin, when present, must be same-origin with Host.
193
+ const origin = req.headers.origin
194
+ if (origin !== undefined) {
195
+ // Duplicate Origin headers (joined or arrayed by Node) never parse as a
196
+ // single valid origin — fail closed.
197
+ if (Array.isArray(origin) || !originMatchesAuthority(origin, authority)) {
198
+ return forbid('origin_mismatch')
199
+ }
200
+ }
201
+ if (declaresCrossSite(req.headers['sec-fetch-site'])) {
202
+ return forbid('cross_site')
203
+ }
204
+
205
+ return OK
206
+ }
207
+
208
+ /**
209
+ * Full HTTP-route guard, layers 1-4 in order:
210
+ *
211
+ * 1. `socket.remoteAddress` must be loopback → else 403;
212
+ * 2. `Host` must be a loopback authority → else 403;
213
+ * 3. `Origin` (when present) must be same-origin with Host, and
214
+ * `sec-fetch-site: cross-site` is explicitly refused → else 403;
215
+ * 4. POST/PUT/PATCH must carry `content-type: application/json` (charset
216
+ * parameter allowed) → else 415.
217
+ *
218
+ * Layer 5 (the write-action gate) is {@link guardWriteAction}: routes call
219
+ * it only for state-changing actions, chaining this verdict through.
220
+ *
221
+ * @param req - the incoming request (or a structural mock in tests).
222
+ * @param _opts - reserved; layers 1-4 need no dynamic settings today.
223
+ */
224
+ export function guardRequest(
225
+ req: GuardableRequest,
226
+ _opts?: GuardOptions,
227
+ ): GuardVerdict {
228
+ const reachability = guardReachability(req)
229
+ if (!reachability.ok) return reachability
230
+
231
+ // Layer 4 — CSRF mitigation: body-bearing methods must be JSON, which
232
+ // forces a CORS preflight and blocks cross-site "simple" form posts.
233
+ const method = (req.method ?? '').toUpperCase()
234
+ if (BODY_METHODS.has(method)) {
235
+ const contentType = req.headers['content-type']
236
+ const mime =
237
+ typeof contentType === 'string'
238
+ ? contentType.split(';', 1)[0]?.trim().toLowerCase()
239
+ : undefined
240
+ if (mime !== 'application/json') {
241
+ return { ok: false, status: 415, reason: 'unsupported_media_type' }
242
+ }
243
+ }
244
+
245
+ return OK
246
+ }
247
+
248
+ /**
249
+ * Layer 5 — write-action gate. Chains an earlier verdict (typically from
250
+ * {@link guardRequest}) and then requires `inject.enabled` to be on, read
251
+ * live via {@link GuardOptions.allowWriteActions}. The one-time confirmToken
252
+ * check is the M2 inject gateway's job, not this layer's.
253
+ *
254
+ * @param verdictCtx - verdict from the preceding layers; failures pass through.
255
+ * @param opts - dynamic settings source; gate is closed when it says so.
256
+ */
257
+ export function guardWriteAction(
258
+ verdictCtx: GuardVerdict,
259
+ opts: GuardOptions,
260
+ ): GuardVerdict {
261
+ if (!verdictCtx.ok) return verdictCtx
262
+ if (!opts.allowWriteActions()) return forbid('inject_disabled')
263
+ return OK
264
+ }
265
+
266
+ /**
267
+ * WS upgrade guard: layers 1-3 only (an upgrade has no JSON body to gate).
268
+ * A failing verdict means the caller must destroy the socket.
269
+ */
270
+ export function guardUpgrade(
271
+ req: Pick<IncomingMessage, 'headers' | 'socket'>,
272
+ ): GuardVerdict {
273
+ return guardReachability(req)
274
+ }