@goodandready/dsh-github-ops 0.2.9 → 0.2.11
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 +26 -0
- package/lib/client.js +14 -7
- package/lib/github.js +14 -0
- package/lib/index.js +15 -18
- package/lib/present.js +2 -2
- package/lib/routes.js +13 -3
- package/lib/status.js +76 -22
- package/lib/tools/insights.js +1 -1
- package/lib/tools/issues.js +1 -0
- package/lib/tools/mirror.js +32 -1
- package/lib/tools/pulls.js +5 -1
- package/lib/tools/register-core.js +2 -1
- package/lib/tools/register-write.js +1 -1
- package/lib/tools/releases.js +43 -5
- package/lib/tools/repos.js +3 -2
- package/lib/tools/write-files.js +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.11
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **Strict CSRF & Origin Verification on SCM Writes** (#98, #81, #99): Added `isTrustedWriteRequest` guarding `POST /scm`. Discarded client-supplied `X-Forwarded-Host` in favor of validated `Host` header. Enforced strict fail-closed policy on cross-site requests (`Sec-Fetch-Site: cross-site`) across loopback and local networks.
|
|
7
|
+
- **Path Segment Encoding & Traversal Defense** (#87): Added `encodeRepoPath` to prevent directory traversal (`..`) and URI-encode path segments in GitHub Contents API endpoints (`getFile`, `deleteFile`, and blob viewer route).
|
|
8
|
+
- **Mirror Publication Isolation & Option Injection Defense** (#83, #91): Added strict validation on mirror destination URLs and branch names (`validateRemote`, `validateBranchName`) to reject leading hyphen flags and CLI option injection into git arguments. Replaced predictable temp index paths with `crypto.randomUUID()`-backed paths in `tmpdir()`.
|
|
9
|
+
- **Release Asset Upload Workspace Containment** (#82): Restricted release asset file uploads to files within the workspace root or allowlisted local files, rejecting system directories (`/etc`, `/proc`, `/sys`, etc.), traversal paths, and control characters.
|
|
10
|
+
|
|
11
|
+
## 0.2.10
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Settings Surface & Volatile Fields on DSH 0.2** (#76, #85): Config fields declare `.volatile()` wrappers and live settings reads are unwrapped via `plainConfig()` to comply with the DSH 0.2 live-value contract and prevent settings from being marked unavailable.
|
|
15
|
+
- **Dead settingsScope.replace Removed** (#100, #86): Removed unused dead `settingsScope.replace` method that swallowed errors.
|
|
16
|
+
- **Client Fetch AbortSignal Timeout** (#101): Attached 15s/120s `AbortSignal` timeouts to all `fetch` calls in `lib/client.js` to prevent the UI from hanging indefinitely on stalled requests.
|
|
17
|
+
- **Git Runner Timeout** (#90): SCM route passes `timeoutMs` (120000ms) to `execFileAsync` git runner options, properly enforcing timeouts on write operations.
|
|
18
|
+
- **Presenter Interpolation Format** (#94): Fixed entity number interpolation in `issue_comment` (`#${number}`) and `pr_merge` (`Merged pull request #${number}`) cards.
|
|
19
|
+
- **GraphQL Thread Mutation Validation** (#88): `resolveReviewThread` validates return thread existence and throws on empty/null responses instead of reporting false success.
|
|
20
|
+
- **Merged PRs Filter** (#89): `mergedPulls` in insights now verifies `mergedAt` presence instead of counting closed-unmerged pull requests.
|
|
21
|
+
- **Auth Device Secret Await** (#84): Fixed missing `await auth.secret()` in `register-write.js` that was passing a Promise instance as OAuth `client_secret`.
|
|
22
|
+
|
|
23
|
+
## 0.2.9
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Audit Findings & Stability**: Pre-release audit bundle addressing review thread mutations, auth credentials and input validation.
|
|
27
|
+
|
|
28
|
+
|
|
3
29
|
## 0.2.8
|
|
4
30
|
|
|
5
31
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -20,6 +20,13 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
const SCM_PATH = '/dsh-github-ops/scm'
|
|
21
21
|
const PANEL_PATH = '/dsh-github-ops/panel'
|
|
22
22
|
|
|
23
|
+
const fetchWithTimeout = (url, options = {}, timeoutMs = 15000) => {
|
|
24
|
+
const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
|
|
25
|
+
? AbortSignal.timeout(timeoutMs)
|
|
26
|
+
: undefined
|
|
27
|
+
return fetch(url, signal ? { signal, ...options } : options)
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
// The repository a github.com link in the conversation pointed at, so the tab and the
|
|
24
31
|
// panel can follow the human instead of asking again.
|
|
25
32
|
const linkState = { repo: '', enabled: false }
|
|
@@ -283,7 +290,7 @@ window.__ModuleLoader__.load({
|
|
|
283
290
|
setState({ phase: 'error', message: 'fetch is unavailable in this client' })
|
|
284
291
|
return
|
|
285
292
|
}
|
|
286
|
-
|
|
293
|
+
fetchWithTimeout(cwdQuery ? `${SCM_PATH}?${cwdQuery}` : SCM_PATH, { headers: { accept: 'application/json' } })
|
|
287
294
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
288
295
|
.then((payload) => setState({ phase: 'ready', payload }))
|
|
289
296
|
.catch((err) => setState({ phase: 'error', message: String((err && err.message) || err) }))
|
|
@@ -296,11 +303,11 @@ window.__ModuleLoader__.load({
|
|
|
296
303
|
if (typeof fetch !== 'function') return
|
|
297
304
|
setBusy(action)
|
|
298
305
|
setActionError('')
|
|
299
|
-
|
|
306
|
+
fetchWithTimeout(SCM_PATH, {
|
|
300
307
|
method: 'POST',
|
|
301
308
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
302
309
|
body: JSON.stringify(repoPath ? { action, args: args || {}, cwd: repoPath } : { action, args: args || {} }),
|
|
303
|
-
})
|
|
310
|
+
}, 120000)
|
|
304
311
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
305
312
|
.then((payload) => {
|
|
306
313
|
setBusy('')
|
|
@@ -322,7 +329,7 @@ window.__ModuleLoader__.load({
|
|
|
322
329
|
setSelected({ path: file.path, staged })
|
|
323
330
|
setDiff({ phase: 'loading', text: '' })
|
|
324
331
|
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
|
|
325
|
-
|
|
332
|
+
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
326
333
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
327
334
|
.then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
|
|
328
335
|
.catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
|
|
@@ -518,7 +525,7 @@ window.__ModuleLoader__.load({
|
|
|
518
525
|
|
|
519
526
|
const load = React.useCallback(() => {
|
|
520
527
|
if (typeof fetch !== 'function') return
|
|
521
|
-
|
|
528
|
+
fetchWithTimeout(SCM_PATH, { headers: { accept: 'application/json' } })
|
|
522
529
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
523
530
|
.then((payload) => setState({ phase: 'ready', payload }))
|
|
524
531
|
.catch(() => setState({ phase: 'idle' }))
|
|
@@ -602,7 +609,7 @@ window.__ModuleLoader__.load({
|
|
|
602
609
|
const call = React.useCallback((params) => {
|
|
603
610
|
if (typeof fetch !== 'function') return Promise.reject(new Error('fetch is unavailable in this client'))
|
|
604
611
|
const query = new URLSearchParams(params).toString()
|
|
605
|
-
return
|
|
612
|
+
return fetchWithTimeout(`${PANEL_PATH}?${query}`, { headers: { accept: 'application/json' } })
|
|
606
613
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
607
614
|
}, [])
|
|
608
615
|
|
|
@@ -841,7 +848,7 @@ window.__ModuleLoader__.load({
|
|
|
841
848
|
setStatusError('fetch is unavailable in this client')
|
|
842
849
|
return
|
|
843
850
|
}
|
|
844
|
-
|
|
851
|
+
fetchWithTimeout(STATUS_PATH, { headers: { accept: 'application/json' } })
|
|
845
852
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
846
853
|
.then((payload) => setAccessStatus(payload))
|
|
847
854
|
.catch((err) => { setAccessStatus(null); setStatusError(String((err && err.message) || err)) })
|
package/lib/github.js
CHANGED
|
@@ -424,3 +424,17 @@ export function describeError(err) {
|
|
|
424
424
|
}
|
|
425
425
|
return String(err.message || err)
|
|
426
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Safely encode a repository file path segment for the GitHub contents API.
|
|
430
|
+
* Rejects path traversal (..) and encodes each path component.
|
|
431
|
+
*/
|
|
432
|
+
export function encodeRepoPath(rawPath) {
|
|
433
|
+
const cleaned = String(rawPath == null ? '' : rawPath).trim().replace(/^\/+/, '')
|
|
434
|
+
if (!cleaned) throw new Error('path is required')
|
|
435
|
+
const segments = cleaned.split('/')
|
|
436
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
437
|
+
throw new Error('path must not contain traversal segments')
|
|
438
|
+
}
|
|
439
|
+
return segments.map(encodeURIComponent).join('/')
|
|
440
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -44,11 +44,12 @@ const callContext = new AsyncLocalStorage()
|
|
|
44
44
|
|
|
45
45
|
/** git runner: never throws, always answers with a code and both streams. */
|
|
46
46
|
function makeGitRunner() {
|
|
47
|
-
return async (args, { cwd, env } = {}) => {
|
|
47
|
+
return async (args, { cwd, env, timeoutMs, timeout } = {}) => {
|
|
48
48
|
try {
|
|
49
49
|
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
50
50
|
cwd: cwd || process.cwd(),
|
|
51
51
|
env: { ...process.env, ...(env || {}) },
|
|
52
|
+
timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
|
|
52
53
|
maxBuffer: 8 * 1024 * 1024,
|
|
53
54
|
})
|
|
54
55
|
return { code: 0, stdout, stderr }
|
|
@@ -171,25 +172,21 @@ export const Config = Schema.object({
|
|
|
171
172
|
})
|
|
172
173
|
|
|
173
174
|
export function apply(ctx, rawConfig) {
|
|
174
|
-
const config = { ...Config(), ...(rawConfig || {}) }
|
|
175
|
+
const config = plainConfig({ ...Config(), ...(rawConfig || {}) })
|
|
175
176
|
let settingsScope = null
|
|
176
177
|
|
|
177
178
|
ctx.inject(['settings'], (sctx) => {
|
|
178
179
|
try {
|
|
179
180
|
settingsScope = {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return await sctx.settings?.replace?.(name, next, row?.revision);
|
|
190
|
-
} catch {}
|
|
191
|
-
},
|
|
192
|
-
}
|
|
181
|
+
get: () => {
|
|
182
|
+
try {
|
|
183
|
+
const row = sctx.settings?.describe?.().find((r) => r && (r.ns === name || r.ns === '@goodandready/dsh-github-ops'))
|
|
184
|
+
return plainConfig(row?.value ?? config ?? {})
|
|
185
|
+
} catch {
|
|
186
|
+
return plainConfig(config ?? {})
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
}
|
|
193
190
|
} catch (err) {
|
|
194
191
|
ctx.logger?.warn?.('[dsh-github-ops] settings namespace unavailable:', err && err.message)
|
|
195
192
|
}
|
|
@@ -211,9 +208,9 @@ export function apply(ctx, rawConfig) {
|
|
|
211
208
|
function liveConfig() {
|
|
212
209
|
try {
|
|
213
210
|
const fromScope = settingsScope && settingsScope.get && settingsScope.get()
|
|
214
|
-
return { ...config, ...(fromScope || {}) }
|
|
211
|
+
return plainConfig({ ...config, ...(fromScope || {}) })
|
|
215
212
|
} catch {
|
|
216
|
-
return config
|
|
213
|
+
return plainConfig(config)
|
|
217
214
|
}
|
|
218
215
|
}
|
|
219
216
|
|
|
@@ -378,7 +375,7 @@ export function apply(ctx, rawConfig) {
|
|
|
378
375
|
},
|
|
379
376
|
}
|
|
380
377
|
|
|
381
|
-
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
|
|
378
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd() }
|
|
382
379
|
registerCoreTools(deps)
|
|
383
380
|
registerCollabTools(deps)
|
|
384
381
|
registerOpsTools(deps)
|
package/lib/present.js
CHANGED
|
@@ -102,7 +102,7 @@ const PRESENTATIONS = {
|
|
|
102
102
|
},
|
|
103
103
|
issue_comment: {
|
|
104
104
|
call: (args) => pending(`Comment on #${args.number}`, pick(args, ['repository', 'number'])),
|
|
105
|
-
result: (_args, r) => failure('Comment not posted', r) || text(`Commented on #${r.data.id
|
|
105
|
+
result: (_args, r) => failure('Comment not posted', r) || text(`Commented on #${_args?.number || (r.data && r.data.id) || ''}`, `comment id ${r.data.id}`),
|
|
106
106
|
},
|
|
107
107
|
issue_close: {
|
|
108
108
|
call: (args) => pending(`Close #${args.number}`, pick(args, ['repository', 'number', 'stateReason'])),
|
|
@@ -126,7 +126,7 @@ const PRESENTATIONS = {
|
|
|
126
126
|
pr_merge: {
|
|
127
127
|
call: (args) => pending(`Merge pull request #${args.number}`, pick(args, ['repository', 'number', 'method', 'deleteBranch'])),
|
|
128
128
|
result: (_args, r) => failure('Pull request not merged', r)
|
|
129
|
-
|| text(r.data.merged ? `Merged #${
|
|
129
|
+
|| text(r.data.merged ? `Merged pull request #${_args?.number || (r.data && r.data.number) || ''}` : 'Merge not performed', `${r.data.message || ''}${r.data.sha ? `\n${String(r.data.sha).slice(0, 7)}` : ''}${r.data.branchDeleted ? `\nhead branch ${r.data.branchDeleted} deleted` : ''}`),
|
|
130
130
|
},
|
|
131
131
|
gh_review: {
|
|
132
132
|
call: (args) => pending(`Review pull request #${args.number}`, pick(args, ['repository', 'number', 'maxDiffChars'])),
|
package/lib/routes.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, statSync } from 'node:fs'
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
|
-
import { statusPayload, isTrustedRequest, sendJson } from './status.js'
|
|
11
|
+
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
12
12
|
import { buildAction, validateCwd } from './scm-write.js'
|
|
13
13
|
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
|
|
14
14
|
import { listIssues } from './tools/issues.js'
|
|
@@ -94,7 +94,11 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
94
94
|
path: SCM_PATH,
|
|
95
95
|
handler: async (req, res) => {
|
|
96
96
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
97
|
-
if (
|
|
97
|
+
if (req.method === 'POST') {
|
|
98
|
+
if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
99
|
+
} else {
|
|
100
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
101
|
+
}
|
|
98
102
|
const git = makeGitRunner()
|
|
99
103
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
100
104
|
const file = url.searchParams.get('file') || ''
|
|
@@ -254,7 +258,13 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
254
258
|
return sendJson(res, 200, { configured: true, ...view })
|
|
255
259
|
}
|
|
256
260
|
if (what === 'blob') {
|
|
257
|
-
|
|
261
|
+
let encodedPath = ''
|
|
262
|
+
try {
|
|
263
|
+
encodedPath = encodeRepoPath(path)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return sendJson(res, 400, { configured: true, error: (err && err.message) || String(err) })
|
|
266
|
+
}
|
|
267
|
+
const { data } = await github.get(`/repos/${repoOwner}/${repoName}/contents/${encodedPath}`, { query: ref ? { ref } : undefined })
|
|
258
268
|
if (Array.isArray(data)) return sendJson(res, 200, { configured: true, entries: data.map((e) => ({ name: e.name, type: e.type, size: e.size, path: e.path })) })
|
|
259
269
|
const encoded = String((data && data.content) || '').replace(/\n/g, '')
|
|
260
270
|
const text = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : ''
|
package/lib/status.js
CHANGED
|
@@ -7,43 +7,97 @@
|
|
|
7
7
|
// The payload never contains the access value. The route answers only a local or same-origin
|
|
8
8
|
// caller, because the payload describes the machine's account.
|
|
9
9
|
|
|
10
|
+
/** Whether a request may read the status payload. Fail-closed. */
|
|
11
|
+
function isTrustedHost(host) {
|
|
12
|
+
if (!host || typeof host !== 'string') return false
|
|
13
|
+
let hostname = host.trim()
|
|
14
|
+
if (hostname.startsWith('[')) {
|
|
15
|
+
const end = hostname.indexOf(']')
|
|
16
|
+
if (end > 0) hostname = hostname.slice(1, end)
|
|
17
|
+
} else {
|
|
18
|
+
hostname = hostname.split(':')[0]
|
|
19
|
+
}
|
|
20
|
+
hostname = hostname.toLowerCase()
|
|
21
|
+
if (!hostname) return false
|
|
22
|
+
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.lan')) return true
|
|
23
|
+
const clean = hostname.replace(/^::ffff:/, '')
|
|
24
|
+
if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true
|
|
25
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
26
|
+
const match172 = clean.match(/^172\.(\d+)\./)
|
|
27
|
+
if (match172) {
|
|
28
|
+
const octet = parseInt(match172[1], 10)
|
|
29
|
+
if (octet >= 16 && octet <= 31) return true
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/** Whether a request may read the status payload. Fail-closed. */
|
|
11
35
|
export function isTrustedRequest(req) {
|
|
12
36
|
if (!req) return false
|
|
13
|
-
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
14
|
-
const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
15
|
-
if (loopback) return true
|
|
16
37
|
|
|
17
|
-
//
|
|
38
|
+
// Fail-closed against cross-site fetches regardless of source IP
|
|
18
39
|
const secFetchSite = header(req, 'sec-fetch-site')
|
|
19
40
|
if (secFetchSite === 'cross-site') return false
|
|
20
41
|
|
|
21
|
-
const
|
|
42
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
43
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
44
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
45
|
+
|
|
46
|
+
const host = header(req, 'host')
|
|
22
47
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
23
|
-
if (!host || !origin) return false
|
|
24
48
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
49
|
+
// If host is provided, it MUST be a trusted local/private host (DNS rebinding guard)
|
|
50
|
+
if (host && !isTrustedHost(host)) return false
|
|
51
|
+
|
|
52
|
+
// If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
|
|
53
|
+
if (!origin && !secFetchSite && loopback) return true
|
|
28
54
|
|
|
55
|
+
// If origin/referer is present, verify it matches host or is a trusted local host
|
|
56
|
+
if (origin) {
|
|
57
|
+
try {
|
|
58
|
+
const originUrl = new URL(origin)
|
|
59
|
+
if (!isTrustedHost(originUrl.hostname)) return false
|
|
60
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
29
66
|
|
|
67
|
+
// Non-loopback caller without origin/referer is refused
|
|
68
|
+
if (!loopback) return false
|
|
30
69
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const isPrivate =
|
|
34
|
-
hostname === 'localhost' ||
|
|
35
|
-
hostname === '127.0.0.1' ||
|
|
36
|
-
hostname === '::1' ||
|
|
37
|
-
/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
38
|
-
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
39
|
-
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
40
|
-
hostname.endsWith('.local') ||
|
|
41
|
-
hostname.endsWith('.lan')
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
42
72
|
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
/** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
|
|
74
|
+
export function isTrustedWriteRequest(req) {
|
|
75
|
+
if (!req) return false
|
|
76
|
+
if (!isTrustedRequest(req)) return false
|
|
77
|
+
|
|
78
|
+
const secFetchSite = header(req, 'sec-fetch-site')
|
|
79
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
|
|
45
80
|
return false
|
|
46
81
|
}
|
|
82
|
+
|
|
83
|
+
const host = header(req, 'host')
|
|
84
|
+
const origin = header(req, 'origin') || header(req, 'referer')
|
|
85
|
+
if (origin) {
|
|
86
|
+
try {
|
|
87
|
+
const originUrl = new URL(origin)
|
|
88
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Write request without origin: only permit local loopback
|
|
94
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
95
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
96
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
97
|
+
if (!loopback) return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return true
|
|
47
101
|
}
|
|
48
102
|
|
|
49
103
|
function header(req, name) {
|
package/lib/tools/insights.js
CHANGED
|
@@ -102,7 +102,7 @@ export async function weeklyDigest(client, { owner, repo, days = 7, limit = 10 }
|
|
|
102
102
|
since,
|
|
103
103
|
releases: releases.releases.filter((r) => after(r.publishedAt || r.createdAt)),
|
|
104
104
|
newIssues: issues.issues.filter((i) => after(i.createdAt)),
|
|
105
|
-
mergedPulls: pulls.issues.filter((i) =>
|
|
105
|
+
mergedPulls: pulls.issues.filter((i) => Boolean(i.mergedAt) && after(i.mergedAt)),
|
|
106
106
|
commits: commits.commits,
|
|
107
107
|
}
|
|
108
108
|
}
|
package/lib/tools/issues.js
CHANGED
|
@@ -26,6 +26,7 @@ export function normalizeIssue(data, { mine = '' } = {}) {
|
|
|
26
26
|
createdAt: data.created_at,
|
|
27
27
|
updatedAt: data.updated_at,
|
|
28
28
|
closedAt: data.closed_at,
|
|
29
|
+
mergedAt: (data.pull_request && data.pull_request.merged_at) || data.merged_at || null,
|
|
29
30
|
url: data.html_url,
|
|
30
31
|
body: typeof data.body === 'string' ? data.body : '',
|
|
31
32
|
isMine: mine ? (data.user && data.user.login) === mine : undefined,
|
package/lib/tools/mirror.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
1
4
|
// Sanitized mirror publication.
|
|
2
5
|
//
|
|
3
6
|
// A GitHub mirror is a product channel, not a copy of the history: only the npm
|
|
@@ -76,7 +79,30 @@ async function readManifest(git, { cwd, ref }) {
|
|
|
76
79
|
* Read-only plan: which files would be published, how many would be left behind, and
|
|
77
80
|
* whether the publication must be refused (forbidden path present, required file missing).
|
|
78
81
|
*/
|
|
82
|
+
|
|
83
|
+
const REMOTE_NAME_RE = /^(?!-)[a-zA-Z0-9._/-]+$/
|
|
84
|
+
const BRANCH_NAME_RE = /^(?!-)[a-zA-Z0-9._/-]+$/
|
|
85
|
+
|
|
86
|
+
export function validateRemote(remote) {
|
|
87
|
+
const val = String(remote || '').trim()
|
|
88
|
+
if (!val || val.startsWith('-') || !REMOTE_NAME_RE.test(val)) {
|
|
89
|
+
throw new Error(`invalid mirror remote name: "${remote}"`)
|
|
90
|
+
}
|
|
91
|
+
return val
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateBranchName(branch) {
|
|
95
|
+
const val = String(branch || '').trim()
|
|
96
|
+
if (!val || val.startsWith('-') || !BRANCH_NAME_RE.test(val)) {
|
|
97
|
+
throw new Error(`invalid branch name: "${branch}"`)
|
|
98
|
+
}
|
|
99
|
+
return val
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
export async function planMirror({ git, cwd, ref = 'origin/main', allow, forbidden, required = DEFAULT_REQUIRED }) {
|
|
103
|
+
if (String(ref || '').trim().startsWith('-')) {
|
|
104
|
+
throw new Error(`invalid source ref: "${ref}"`)
|
|
105
|
+
}
|
|
80
106
|
const manifest = await readManifest(git, { cwd, ref })
|
|
81
107
|
const allowlist = allow || allowlistFromManifest(manifest)
|
|
82
108
|
const ls = await git(['ls-tree', '-r', '--name-only', ref], { cwd })
|
|
@@ -115,6 +141,11 @@ export async function publishMirror({
|
|
|
115
141
|
git, cwd, ref = 'origin/main', mirror = 'github', branch = 'main',
|
|
116
142
|
allow, forbidden, required = DEFAULT_REQUIRED, dryRun = false, message,
|
|
117
143
|
}) {
|
|
144
|
+
validateRemote(mirror)
|
|
145
|
+
validateBranchName(branch)
|
|
146
|
+
if (String(ref || '').trim().startsWith('-')) {
|
|
147
|
+
throw new Error(`invalid source ref: "${ref}"`)
|
|
148
|
+
}
|
|
118
149
|
const plan = await planMirror({ git, cwd, ref, allow, forbidden, required })
|
|
119
150
|
if (plan.refused) {
|
|
120
151
|
const err = new Error(plan.refused)
|
|
@@ -131,7 +162,7 @@ export async function publishMirror({
|
|
|
131
162
|
|
|
132
163
|
if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
|
|
133
164
|
|
|
134
|
-
const indexFile =
|
|
165
|
+
const indexFile = path.join(tmpdir(), `dsh-mirror-${process.pid}-${randomUUID()}.index`)
|
|
135
166
|
const env = { GIT_INDEX_FILE: indexFile }
|
|
136
167
|
try {
|
|
137
168
|
await git(['read-tree', '--empty'], { cwd, env })
|
package/lib/tools/pulls.js
CHANGED
|
@@ -329,8 +329,12 @@ export async function resolveReviewThread(client, { owner, repo, threadId, resol
|
|
|
329
329
|
? data?.resolveReviewThread?.thread
|
|
330
330
|
: data?.unresolveReviewThread?.thread
|
|
331
331
|
|
|
332
|
+
if (!thread) {
|
|
333
|
+
throw new Error(`failed to ${shouldResolve ? 'resolve' : 'unresolve'} review thread: mutation returned no thread`)
|
|
334
|
+
}
|
|
335
|
+
|
|
332
336
|
return {
|
|
333
337
|
threadId,
|
|
334
|
-
isResolved:
|
|
338
|
+
isResolved: Boolean(thread.isResolved),
|
|
335
339
|
}
|
|
336
340
|
}
|
|
@@ -14,7 +14,7 @@ const REPOSITORY = {
|
|
|
14
14
|
repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
17
|
+
export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd }) {
|
|
18
18
|
const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
|
|
19
19
|
|
|
20
20
|
// ------------------------------------------------------------------ repository
|
|
@@ -223,6 +223,7 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
223
223
|
name: args.name,
|
|
224
224
|
label: args.label,
|
|
225
225
|
contentType: args.contentType,
|
|
226
|
+
cwd: typeof defaultCwd === 'function' ? defaultCwd() : undefined,
|
|
226
227
|
})
|
|
227
228
|
},
|
|
228
229
|
)
|
|
@@ -188,7 +188,7 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
188
188
|
const cfg = authConfig()
|
|
189
189
|
const outcome = await pollDeviceFlow({
|
|
190
190
|
clientId: cfg.clientId || DEFAULT_CLIENT_ID,
|
|
191
|
-
clientSecret: auth.secret(),
|
|
191
|
+
clientSecret: await auth.secret(),
|
|
192
192
|
deviceCode: pending.deviceCode,
|
|
193
193
|
intervalSeconds: pending.intervalSeconds,
|
|
194
194
|
maxWaitMs: args.maxWaitMs,
|
package/lib/tools/releases.js
CHANGED
|
@@ -139,23 +139,61 @@ export async function listReleaseAssets(client, { owner, repo, tag, releaseId, l
|
|
|
139
139
|
return { assets, count: assets.length, releaseId: id, rateLimit }
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
const DISALLOWED_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/root', '/bin', '/sbin', '/usr', '/boot', '/var/run']
|
|
143
|
+
|
|
144
|
+
function checkDisallowedRoots(resolvedPath, sep) {
|
|
145
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
146
|
+
if (resolvedPath === root || resolvedPath.startsWith(root + sep) || resolvedPath.startsWith(root + '/')) {
|
|
147
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function uploadReleaseAsset(client, { owner, repo, tag, releaseId, filePath, name, label, contentType, cwd }) {
|
|
143
153
|
ensureRepo({ owner, repo })
|
|
144
|
-
if (!filePath) throw new Error('filePath is required')
|
|
145
|
-
|
|
154
|
+
if (!filePath || typeof filePath !== 'string') throw new Error('filePath is required')
|
|
155
|
+
if (/[\x00-\x1f]/.test(filePath)) throw new Error('filePath may not contain control characters')
|
|
146
156
|
|
|
147
157
|
const fs = await import('node:fs/promises')
|
|
148
158
|
const path = await import('node:path')
|
|
149
159
|
|
|
160
|
+
const baseDir = cwd ? path.resolve(cwd) : null
|
|
161
|
+
const resolvedPath = baseDir ? path.resolve(baseDir, filePath) : path.resolve(filePath)
|
|
162
|
+
|
|
163
|
+
if (baseDir) {
|
|
164
|
+
if (resolvedPath !== baseDir && !resolvedPath.startsWith(baseDir + path.sep)) {
|
|
165
|
+
throw new Error(`filePath must reside within the workspace directory: ${filePath}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
checkDisallowedRoots(resolvedPath, path.sep)
|
|
170
|
+
|
|
171
|
+
let realTarget = resolvedPath
|
|
172
|
+
try {
|
|
173
|
+
realTarget = await fs.realpath(resolvedPath)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new Error(`cannot access file "${filePath}": ${err && err.message}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
checkDisallowedRoots(realTarget, path.sep)
|
|
179
|
+
|
|
180
|
+
if (baseDir) {
|
|
181
|
+
const realBase = await fs.realpath(baseDir).catch(() => baseDir)
|
|
182
|
+
if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
|
|
183
|
+
throw new Error(`filePath must reside within the workspace directory: ${filePath}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
150
187
|
let stats
|
|
151
188
|
try {
|
|
152
|
-
stats = await fs.stat(
|
|
189
|
+
stats = await fs.stat(realTarget)
|
|
153
190
|
} catch (err) {
|
|
154
191
|
throw new Error(`cannot access file "${filePath}": ${err && err.message}`)
|
|
155
192
|
}
|
|
156
193
|
if (!stats.isFile()) throw new Error(`"${filePath}" is not a file`)
|
|
157
194
|
|
|
158
|
-
const
|
|
195
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
196
|
+
const fileBuffer = await fs.readFile(realTarget)
|
|
159
197
|
const assetName = name || path.basename(filePath)
|
|
160
198
|
const mimeType = contentType || 'application/octet-stream'
|
|
161
199
|
|
package/lib/tools/repos.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeRepoPath } from '../github.js'
|
|
1
2
|
// Repository and file operations: metadata, file content at a ref, repository
|
|
2
3
|
// search (its own quota), plus repository creation and edits.
|
|
3
4
|
|
|
@@ -36,8 +37,8 @@ export async function getRepo(client, { owner, repo }) {
|
|
|
36
37
|
|
|
37
38
|
export async function getFile(client, { owner, repo, path, ref }) {
|
|
38
39
|
ensureRepo({ owner, repo })
|
|
39
|
-
|
|
40
|
-
const { data } = await client.get(`/repos/${owner}/${repo}/contents/${
|
|
40
|
+
const encodedPath = encodeRepoPath(path)
|
|
41
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
41
42
|
query: ref ? { ref } : undefined,
|
|
42
43
|
})
|
|
43
44
|
if (Array.isArray(data)) {
|
package/lib/tools/write-files.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeRepoPath } from '../github.js'
|
|
1
2
|
// Writing files without a local checkout.
|
|
2
3
|
//
|
|
3
4
|
// A commit is built the way git builds it: blobs, a tree on top of the branch's tree, a
|
|
@@ -185,14 +186,14 @@ export async function uploadProject(client, {
|
|
|
185
186
|
/** Delete one file, resolving its blob sha first (the contents API requires it). */
|
|
186
187
|
export async function deleteFile(client, { owner, repo, path, message, branch } = {}) {
|
|
187
188
|
ensureRepo({ owner, repo })
|
|
188
|
-
if (!path) throw new Error('path is required')
|
|
189
189
|
if (!message) throw new Error('message is required')
|
|
190
|
-
const
|
|
190
|
+
const encodedPath = encodeRepoPath(path)
|
|
191
|
+
const { data: current } = await client.get(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
191
192
|
query: branch ? { ref: branch } : undefined,
|
|
192
193
|
})
|
|
193
194
|
const sha = current && current.sha
|
|
194
195
|
if (!sha) throw new Error(`cannot resolve ${path}`)
|
|
195
|
-
const { data } = await client.del(`/repos/${owner}/${repo}/contents/${
|
|
196
|
+
const { data } = await client.del(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
196
197
|
body: { message, sha, ...(branch ? { branch } : {}) },
|
|
197
198
|
})
|
|
198
199
|
return { deleted: true, path, commit: data && data.commit ? data.commit.sha : null }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|