@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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- 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
|
+
}
|