@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 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
- fetch(cwdQuery ? `${SCM_PATH}?${cwdQuery}` : SCM_PATH, { headers: { accept: 'application/json' } })
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
- fetch(SCM_PATH, {
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
- fetch(SCM_PATH + query, { headers: { accept: 'application/json' } })
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
- fetch(SCM_PATH, { headers: { accept: 'application/json' } })
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 fetch(`${PANEL_PATH}?${query}`, { headers: { accept: 'application/json' } })
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
- fetch(STATUS_PATH, { headers: { accept: 'application/json' } })
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
- get: () => {
181
- try {
182
- const row = sctx.settings?.describe?.().find((r) => r && r.ns === name);
183
- return row?.value ?? config ?? {};
184
- } catch { return config ?? {}; }
185
- },
186
- replace: async (next) => {
187
- try {
188
- const row = sctx.settings?.describe?.().find((r) => r && r.ns === name);
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 ? '' : ''}`, `comment id ${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 #${'pull request'}` : '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` : ''}`),
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 (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
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
- const { data } = await github.get(`/repos/${repoOwner}/${repoName}/contents/${path}`, { query: ref ? { ref } : undefined })
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
- // For non-loopback callers: fail-closed source verification
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 host = header(req, 'x-forwarded-host') || header(req, 'host')
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
- try {
26
- const originUrl = new URL(origin)
27
- if (originUrl.host.toLowerCase() !== host.toLowerCase()) return false
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
- // DNS rebinding guard: host must be a loopback, local or private LAN address
32
- const hostname = host.split(':')[0].toLowerCase()
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
- return isPrivate
44
- } catch {
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) {
@@ -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) => after(i.closedAt || i.updatedAt)),
105
+ mergedPulls: pulls.issues.filter((i) => Boolean(i.mergedAt) && after(i.mergedAt)),
106
106
  commits: commits.commits,
107
107
  }
108
108
  }
@@ -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,
@@ -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 = `/tmp/dsh-github-ops-mirror-${process.pid}-${Date.now()}.index`
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 })
@@ -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: thread ? Boolean(thread.isResolved) : shouldResolve,
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,
@@ -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
- export async function uploadReleaseAsset(client, { owner, repo, tag, releaseId, filePath, name, label, contentType }) {
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
- const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
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(filePath)
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 fileBuffer = await fs.readFile(filePath)
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
 
@@ -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
- if (!path) throw new Error('path is required')
40
- const { data } = await client.get(`/repos/${owner}/${repo}/contents/${path}`, {
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)) {
@@ -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 { data: current } = await client.get(`/repos/${owner}/${repo}/contents/${String(path).replace(/^\/+/, '')}`, {
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/${String(path).replace(/^\/+/, '')}`, {
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.9",
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",