@goodandready/dsh-github-ops 0.2.10 → 0.2.12
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 +20 -0
- package/lib/commands.js +4 -4
- package/lib/github.js +14 -0
- package/lib/index.js +1 -1
- package/lib/routes.js +13 -3
- package/lib/scm-write.js +12 -0
- package/lib/status.js +85 -22
- package/lib/tools/mirror.js +35 -1
- package/lib/tools/register-collab.js +2 -2
- package/lib/tools/register-core.js +17 -8
- package/lib/tools/register-write.js +0 -3
- package/lib/tools/release-preflight.js +6 -1
- package/lib/tools/releases.js +43 -6
- 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,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.12
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **Release Preflight Test Confirmation & CWD Containment** (#80): Required `confirm: true` to execute `npm test` during `gh_release_preflight` audit, and routed audit directory through `validateCwd` to prevent arbitrary directory test execution.
|
|
7
|
+
- **SCM Symlink Traversal Protection** (#96): Enhanced `validateCwd` with `realpathSync` resolution to prevent directory traversal and symlink escape into protected system directories (`/etc`, `/proc`, `/sys`, etc.).
|
|
8
|
+
- **Socket Remote Address Verification & IPv6 Support** (#97): Enforced socket `remoteAddress` validation in `isTrustedRequest` and `isTrustedWriteRequest` to block untrusted external clients from spoofing matching `Host` and `Origin` headers. Fixed unbracketed IPv6 address parsing in `isTrustedHost`.
|
|
9
|
+
|
|
10
|
+
### Refactored
|
|
11
|
+
- **Mirror Git Index Directory Isolation** (#91): Created mirror temporary git-index inside an isolated `fs.mkdtemp` (0700) directory with guaranteed cleanup in `finally`.
|
|
12
|
+
- **Dead Parameter Aliases Removed** (#92): Removed undeclared schema fallback aliases (`tagName`, `targetCommitish`, `previousTagName`, `configurationFilePath`, `pullNumber`) from tool registrations.
|
|
13
|
+
- **Unused Parameters & Re-exports Cleanup** (#95): Removed unused `repoHint` in slash commands, dead `liveConfig()` call in `gh_auth_status`, and unused re-export of `runReleasePreflight`.
|
|
14
|
+
|
|
15
|
+
## 0.2.11
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
- **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.
|
|
19
|
+
- **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).
|
|
20
|
+
- **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()`.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
3
23
|
## 0.2.10
|
|
4
24
|
|
|
5
25
|
### Fixed
|
package/lib/commands.js
CHANGED
|
@@ -72,7 +72,7 @@ export function buildGhInstruction({ topic, tool, args } = {}) {
|
|
|
72
72
|
* Register the commands. Every handler returns an instruction for the model; none of them
|
|
73
73
|
* touches GitHub.
|
|
74
74
|
*/
|
|
75
|
-
export function registerCommands(commands, { readGitState
|
|
75
|
+
export function registerCommands(commands, { readGitState } = {}) {
|
|
76
76
|
const disposers = []
|
|
77
77
|
const context = async () => {
|
|
78
78
|
try {
|
|
@@ -92,7 +92,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
92
92
|
const git = await context()
|
|
93
93
|
return {
|
|
94
94
|
kind: 'success',
|
|
95
|
-
text: buildPrInstruction({ title: rest.join(' '), branch: git.branch, base: git.base || 'main', repo: git.repo
|
|
95
|
+
text: buildPrInstruction({ title: rest.join(' '), branch: git.branch, base: git.base || 'main', repo: git.repo, remoteUrl: git.remoteUrl }),
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
}))
|
|
@@ -107,7 +107,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
107
107
|
const git = await context()
|
|
108
108
|
return {
|
|
109
109
|
kind: 'success',
|
|
110
|
-
text: buildReviewInstruction({ number, branch: git.branch, repo: git.repo
|
|
110
|
+
text: buildReviewInstruction({ number, branch: git.branch, repo: git.repo }),
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
}))
|
|
@@ -119,7 +119,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
119
119
|
handler: async (invocation) => {
|
|
120
120
|
const [sub, ...rest] = String(invocation.rawInput || '').trim().split(/\s+/)
|
|
121
121
|
const git = await context()
|
|
122
|
-
const repo = git.repo
|
|
122
|
+
const repo = git.repo
|
|
123
123
|
if (sub === 'new') return { kind: 'success', text: buildIssueInstruction({ sub, title: rest.join(' '), repo }) }
|
|
124
124
|
if (sub === 'show' && /^\d+$/.test(rest[0] || '')) return { kind: 'success', text: buildIssueInstruction({ sub, number: Number(rest[0]), repo }) }
|
|
125
125
|
if (!sub || sub === 'list') return { kind: 'success', text: buildIssueInstruction({ sub: 'list', repo }) }
|
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
|
@@ -375,7 +375,7 @@ export function apply(ctx, rawConfig) {
|
|
|
375
375
|
},
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
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() }
|
|
379
379
|
registerCoreTools(deps)
|
|
380
380
|
registerCollabTools(deps)
|
|
381
381
|
registerOpsTools(deps)
|
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/scm-write.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
1
2
|
// Writing to a working copy, from the Source Control tab.
|
|
2
3
|
//
|
|
3
4
|
// Every action is a list of argv arrays — never a shell string. The route runs them one by one
|
|
@@ -172,5 +173,16 @@ export function validateCwd(path) {
|
|
|
172
173
|
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
let realPath = value
|
|
177
|
+
try {
|
|
178
|
+
realPath = realpathSync(value)
|
|
179
|
+
} catch {
|
|
180
|
+
realPath = value
|
|
181
|
+
}
|
|
182
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
183
|
+
if (realPath === root || realPath.startsWith(root + '/')) {
|
|
184
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
175
187
|
return value
|
|
176
188
|
}
|
package/lib/status.js
CHANGED
|
@@ -7,43 +7,106 @@
|
|
|
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 if (hostname.includes(':')) {
|
|
18
|
+
const colons = hostname.split(':').length - 1
|
|
19
|
+
if (colons === 1) {
|
|
20
|
+
hostname = hostname.split(':')[0]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
hostname = hostname.toLowerCase()
|
|
24
|
+
if (!hostname) return false
|
|
25
|
+
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.lan')) return true
|
|
26
|
+
const clean = hostname.replace(/^::ffff:/, '')
|
|
27
|
+
if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true
|
|
28
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
29
|
+
const match172 = clean.match(/^172\.(\d+)\./)
|
|
30
|
+
if (match172) {
|
|
31
|
+
const octet = parseInt(match172[1], 10)
|
|
32
|
+
if (octet >= 16 && octet <= 31) return true
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
/** Whether a request may read the status payload. Fail-closed. */
|
|
11
38
|
export function isTrustedRequest(req) {
|
|
12
39
|
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
40
|
|
|
17
|
-
//
|
|
41
|
+
// Fail-closed against cross-site fetches regardless of source IP
|
|
18
42
|
const secFetchSite = header(req, 'sec-fetch-site')
|
|
19
43
|
if (secFetchSite === 'cross-site') return false
|
|
20
44
|
|
|
21
|
-
const
|
|
45
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
46
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
47
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
48
|
+
|
|
49
|
+
// If socket remoteAddress is present, it MUST be a trusted local/private IP (never an untrusted remote IP)
|
|
50
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
51
|
+
|
|
52
|
+
const host = header(req, 'host')
|
|
22
53
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
23
|
-
if (!host || !origin) return false
|
|
24
54
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
55
|
+
// If host is provided, it MUST be a trusted local/private host (DNS rebinding guard)
|
|
56
|
+
if (host && !isTrustedHost(host)) return false
|
|
28
57
|
|
|
58
|
+
// If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
|
|
59
|
+
if (!origin && !secFetchSite && loopback) return true
|
|
29
60
|
|
|
61
|
+
// If origin/referer is present, verify it matches host or is a trusted local host
|
|
62
|
+
if (origin) {
|
|
63
|
+
try {
|
|
64
|
+
const originUrl = new URL(origin)
|
|
65
|
+
if (!isTrustedHost(originUrl.hostname)) return false
|
|
66
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
67
|
+
return true
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
30
72
|
|
|
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')
|
|
73
|
+
// Non-loopback caller without origin/referer is refused
|
|
74
|
+
if (!loopback) return false
|
|
42
75
|
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
|
|
80
|
+
export function isTrustedWriteRequest(req) {
|
|
81
|
+
if (!req) return false
|
|
82
|
+
if (!isTrustedRequest(req)) return false
|
|
83
|
+
|
|
84
|
+
const secFetchSite = header(req, 'sec-fetch-site')
|
|
85
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
|
|
45
86
|
return false
|
|
46
87
|
}
|
|
88
|
+
|
|
89
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
90
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
91
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
92
|
+
|
|
93
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
94
|
+
|
|
95
|
+
const host = header(req, 'host')
|
|
96
|
+
const origin = header(req, 'origin') || header(req, 'referer')
|
|
97
|
+
if (origin) {
|
|
98
|
+
try {
|
|
99
|
+
const originUrl = new URL(origin)
|
|
100
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// Write request without origin: only permit local loopback
|
|
106
|
+
if (!loopback) return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return true
|
|
47
110
|
}
|
|
48
111
|
|
|
49
112
|
function header(req, name) {
|
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,9 @@ export async function publishMirror({
|
|
|
131
162
|
|
|
132
163
|
if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
|
|
133
164
|
|
|
134
|
-
const
|
|
165
|
+
const fs = await import('node:fs/promises')
|
|
166
|
+
const tmpIndexDir = await fs.mkdtemp(path.join(tmpdir(), 'dsh-mirror-index-'))
|
|
167
|
+
const indexFile = path.join(tmpIndexDir, `mirror-${randomUUID()}.index`)
|
|
135
168
|
const env = { GIT_INDEX_FILE: indexFile }
|
|
136
169
|
try {
|
|
137
170
|
await git(['read-tree', '--empty'], { cwd, env })
|
|
@@ -159,5 +192,6 @@ export async function publishMirror({
|
|
|
159
192
|
}
|
|
160
193
|
} finally {
|
|
161
194
|
await git(['update-index', '--refresh'], { cwd, env }).catch(() => {})
|
|
195
|
+
await fs.rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {})
|
|
162
196
|
}
|
|
163
197
|
}
|
|
@@ -245,7 +245,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
245
245
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
246
246
|
return listReviewThreads(await client(), {
|
|
247
247
|
owner, repo,
|
|
248
|
-
number: args.number
|
|
248
|
+
number: args.number,
|
|
249
249
|
unresolvedOnly: Boolean(args.unresolvedOnly),
|
|
250
250
|
})
|
|
251
251
|
},
|
|
@@ -266,7 +266,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
266
266
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
267
267
|
return replyReviewThread(await client(), {
|
|
268
268
|
owner, repo,
|
|
269
|
-
number: args.number
|
|
269
|
+
number: args.number,
|
|
270
270
|
threadId: args.threadId,
|
|
271
271
|
commentId: args.commentId,
|
|
272
272
|
body: args.body,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateCwd } from '../scm-write.js'
|
|
1
2
|
import { runReleasePreflight } from './release-preflight.js'
|
|
2
3
|
// Core tool registrations: repository and file reads, releases, tags, the guarded
|
|
3
4
|
// generic API pass-through, and repository creation/edits.
|
|
@@ -14,7 +15,7 @@ const REPOSITORY = {
|
|
|
14
15
|
repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
18
|
+
export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd }) {
|
|
18
19
|
const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
|
|
19
20
|
|
|
20
21
|
// ------------------------------------------------------------------ repository
|
|
@@ -223,6 +224,7 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
223
224
|
name: args.name,
|
|
224
225
|
label: args.label,
|
|
225
226
|
contentType: args.contentType,
|
|
227
|
+
cwd: typeof defaultCwd === 'function' ? defaultCwd() : undefined,
|
|
226
228
|
})
|
|
227
229
|
},
|
|
228
230
|
)
|
|
@@ -254,10 +256,10 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
254
256
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
255
257
|
return generateReleaseNotes(await client(), {
|
|
256
258
|
owner, repo,
|
|
257
|
-
tag: args.tag
|
|
258
|
-
target: args.target
|
|
259
|
-
previousTag: args.previousTag
|
|
260
|
-
configFile: args.configFile
|
|
259
|
+
tag: args.tag,
|
|
260
|
+
target: args.target,
|
|
261
|
+
previousTag: args.previousTag,
|
|
262
|
+
configFile: args.configFile,
|
|
261
263
|
})
|
|
262
264
|
},
|
|
263
265
|
)
|
|
@@ -336,16 +338,23 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
336
338
|
|
|
337
339
|
tool(
|
|
338
340
|
'gh_release_preflight',
|
|
339
|
-
'Run automated pre-release audit on the plugin package: checks package.json version, cordis.patch.yml, CHANGELOG.md release notes, three READMEs (EN/RU/ZH), npm pack file sizes (<256 KiB), and git index hygiene.',
|
|
341
|
+
'Run automated pre-release audit on the plugin package: checks package.json version, cordis.patch.yml, CHANGELOG.md release notes, three READMEs (EN/RU/ZH), npm pack file sizes (<256 KiB), and git index hygiene. Running test suite requires confirm: true.',
|
|
340
342
|
{
|
|
341
343
|
cwd: { type: 'string', description: 'Directory to audit (defaults to workspace or process.cwd()).' },
|
|
342
|
-
runTests: { type: 'boolean', description: 'Whether to run npm test as part of the audit (
|
|
344
|
+
runTests: { type: 'boolean', description: 'Whether to run npm test as part of the audit (requires confirm: true).' },
|
|
343
345
|
maxFileSize: { type: 'number', description: 'Max allowed size in bytes per packaged file (default 262144).' },
|
|
346
|
+
confirm: { type: 'boolean', description: 'Must be true to execute npm test when runTests is true.' },
|
|
344
347
|
},
|
|
345
348
|
async (args) => {
|
|
349
|
+
if (args.runTests === true && args.confirm !== true) {
|
|
350
|
+
throw new Error('running test suite during preflight audit requires confirm: true')
|
|
351
|
+
}
|
|
352
|
+
const rawCwd = args.cwd || (typeof defaultCwd === 'function' ? defaultCwd() : process.cwd())
|
|
353
|
+
const validatedCwd = validateCwd(rawCwd)
|
|
346
354
|
return runReleasePreflight({
|
|
347
|
-
cwd:
|
|
355
|
+
cwd: validatedCwd,
|
|
348
356
|
runTests: args.runTests,
|
|
357
|
+
confirm: args.confirm,
|
|
349
358
|
maxFileSize: args.maxFileSize,
|
|
350
359
|
})
|
|
351
360
|
},
|
|
@@ -210,7 +210,6 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
210
210
|
'Report where the current access comes from and whether it works: the source (credential, environment, this plugin\'s sign-in, gh CLI), the account, and the remaining API rate limit. The value itself is never shown. Read-only.',
|
|
211
211
|
{},
|
|
212
212
|
async () => {
|
|
213
|
-
const cfg = liveConfig()
|
|
214
213
|
const access = await auth.resolve()
|
|
215
214
|
if (!access.value) return { configured: false, guidance: access.guidance }
|
|
216
215
|
const github = await client()
|
|
@@ -240,6 +239,4 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
240
239
|
},
|
|
241
240
|
)
|
|
242
241
|
|
|
243
|
-
// keep the imports honest for callers that compose their own flows
|
|
244
|
-
return { accessFilePath, readAccessFile }
|
|
245
242
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateCwd } from '../scm-write.js'
|
|
1
2
|
// lib/tools/release-preflight.js
|
|
2
3
|
// Automated release preflight audit for DSH plugins according to
|
|
3
4
|
// dhs-plugin-release-workflow and dsh-documentation-standard.
|
|
@@ -23,8 +24,12 @@ const FORBIDDEN_PACK_EXACT = ['AGENTS.md', '.env', '.env.local', 'deploy.sh']
|
|
|
23
24
|
* @param {object} [deps] Injectable dependencies for unit testing
|
|
24
25
|
*/
|
|
25
26
|
export async function runReleasePreflight(options = {}, deps = {}) {
|
|
26
|
-
const
|
|
27
|
+
const rawCwd = options.cwd || process.cwd()
|
|
28
|
+
const cwd = validateCwd(rawCwd)
|
|
27
29
|
const runTests = options.runTests === true
|
|
30
|
+
if (runTests && options.confirm !== true) {
|
|
31
|
+
throw new Error('running test suite during preflight audit requires confirm: true')
|
|
32
|
+
}
|
|
28
33
|
const maxFileSize = typeof options.maxFileSize === 'number' ? options.maxFileSize : MAX_FILE_SIZE_DEFAULT
|
|
29
34
|
const exec = deps.execFile || pExecFile
|
|
30
35
|
const fsRead = deps.readFileSync || readFileSync
|
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
|
|
|
@@ -198,4 +236,3 @@ export async function generateReleaseNotes(client, { owner, repo, tag, target, p
|
|
|
198
236
|
previousTag: previousTag || '',
|
|
199
237
|
}
|
|
200
238
|
}
|
|
201
|
-
export { runReleasePreflight } from './release-preflight.js'
|
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.12",
|
|
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",
|