@goodandready/dsh-github-ops 0.2.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/lib/client.js +15 -2
- package/lib/commands.js +4 -4
- package/lib/routes.js +14 -1
- package/lib/scm-write.js +23 -4
- package/lib/status.js +17 -5
- package/lib/token-audit.js +84 -0
- package/lib/tools/actions.js +2 -0
- package/lib/tools/mirror.js +4 -1
- package/lib/tools/register-collab.js +2 -2
- package/lib/tools/register-core.js +15 -7
- package/lib/tools/register-ops.js +13 -0
- package/lib/tools/register-write.js +23 -3
- package/lib/tools/release-preflight.js +6 -1
- package/lib/tools/releases.js +0 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Token Capability Diagnostics & Scopes Audit** (#72): Introduced `gh_token_audit` tool (`auditTokenScopes`) providing automated token permission checks (classic vs fine-grained) and early capability warnings for missing repository, workflow, packages, or org administration scopes. Added `scopeWarnings` in `/status` payload.
|
|
7
|
+
- **Selective CI Failure Log Inspector** (#67): Added `gh_run_failure_inspector` tool with regex-based failure step isolation, automatic ANSI sequence stripping, secret token masking, and highlighted failure summaries without needing to download full workflow artifact archives.
|
|
8
|
+
- **Interactive Stash Management & Diff Preview** (#70): Added stash diff preview endpoint (`GET /scm?stash=stash@{N}`) and full lifecycle actions (`stashShow`, `stashApply`, `stashPop`, `stashDrop` with confirmation safety). Updated Source Control UI with quick diff viewer and stash operation buttons.
|
|
9
|
+
|
|
10
|
+
## 0.2.12
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.).
|
|
15
|
+
- **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`.
|
|
16
|
+
|
|
17
|
+
### Refactored
|
|
18
|
+
- **Mirror Git Index Directory Isolation** (#91): Created mirror temporary git-index inside an isolated `fs.mkdtemp` (0700) directory with guaranteed cleanup in `finally`.
|
|
19
|
+
- **Dead Parameter Aliases Removed** (#92): Removed undeclared schema fallback aliases (`tagName`, `targetCommitish`, `previousTagName`, `configurationFilePath`, `pullNumber`) from tool registrations.
|
|
20
|
+
- **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`.
|
|
21
|
+
|
|
3
22
|
## 0.2.11
|
|
4
23
|
|
|
5
24
|
### Security
|
package/lib/client.js
CHANGED
|
@@ -101,6 +101,7 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
scmDelete: 'Delete',
|
|
102
102
|
scmSwitch: 'Switch',
|
|
103
103
|
scmStashSave: 'Save stash',
|
|
104
|
+
scmStashView: 'Diff',
|
|
104
105
|
scmStashApply: 'Apply',
|
|
105
106
|
scmStashPop: 'Pop',
|
|
106
107
|
scmStashDrop: 'Drop',
|
|
@@ -203,6 +204,7 @@ window.__ModuleLoader__.load({
|
|
|
203
204
|
scmDelete: '删除',
|
|
204
205
|
scmSwitch: '切换',
|
|
205
206
|
scmStashSave: '保存 stash',
|
|
207
|
+
scmStashView: '差异',
|
|
206
208
|
scmStashApply: '应用',
|
|
207
209
|
scmStashPop: '弹出',
|
|
208
210
|
scmStashDrop: '删除',
|
|
@@ -328,7 +330,17 @@ window.__ModuleLoader__.load({
|
|
|
328
330
|
const openDiff = (file, staged) => {
|
|
329
331
|
setSelected({ path: file.path, staged })
|
|
330
332
|
setDiff({ phase: 'loading', text: '' })
|
|
331
|
-
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
|
|
333
|
+
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}${cwdQuery ? `&${cwdQuery}` : ''}`
|
|
334
|
+
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
335
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
336
|
+
.then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
|
|
337
|
+
.catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const openStashDiff = (stashRef) => {
|
|
341
|
+
setSelected({ path: stashRef, isStash: true })
|
|
342
|
+
setDiff({ phase: 'loading', text: '' })
|
|
343
|
+
const query = `?stash=${encodeURIComponent(stashRef)}${cwdQuery ? `&${cwdQuery}` : ''}`
|
|
332
344
|
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
333
345
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
334
346
|
.then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
|
|
@@ -460,6 +472,7 @@ window.__ModuleLoader__.load({
|
|
|
460
472
|
onChange: (event) => setStashMessage(event.target.value),
|
|
461
473
|
}),
|
|
462
474
|
button(t('scmStashSave'), () => runAction('stashPush', { message: stashMessage }), { disabled: Boolean(busy) }),
|
|
475
|
+
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashView')} ${entry.ref}`, () => openStashDiff(entry.ref))),
|
|
463
476
|
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashApply')} ${entry.ref}`, () => runAction('stashApply', { ref: entry.ref }))),
|
|
464
477
|
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashPop')} ${entry.ref}`, () => runAction('stashPop', { ref: entry.ref }))),
|
|
465
478
|
(payload.stashes || []).slice(0, 4).map((entry) => button(
|
|
@@ -1128,7 +1141,7 @@ window.__ModuleLoader__.load({
|
|
|
1128
1141
|
exports.SCM_ACTIONS = [
|
|
1129
1142
|
'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
|
|
1130
1143
|
'branchCreate', 'branchCheckout', 'branchDelete',
|
|
1131
|
-
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'continue', 'abort',
|
|
1144
|
+
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow', 'continue', 'abort',
|
|
1132
1145
|
]
|
|
1133
1146
|
exports.PullRequestBar = PullRequestBar
|
|
1134
1147
|
exports.GitHubPanel = GitHubPanel
|
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/routes.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs'
|
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
11
|
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
12
|
-
import { buildAction, validateCwd } from './scm-write.js'
|
|
12
|
+
import { buildAction, validateCwd, validateStashRef } 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'
|
|
15
15
|
import { listRuns } from './tools/actions.js'
|
|
@@ -168,6 +168,19 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
168
168
|
return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
const stash = url.searchParams.get('stash') || ''
|
|
172
|
+
if (stash) {
|
|
173
|
+
let stashRef
|
|
174
|
+
try {
|
|
175
|
+
stashRef = validateStashRef(stash)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return sendJson(res, 400, { error: String((err && err.message) || err) })
|
|
178
|
+
}
|
|
179
|
+
const diff = await git(['stash', 'show', '-p', stashRef], { cwd })
|
|
180
|
+
const text = diff.code === 0 ? diff.stdout : diff.stderr
|
|
181
|
+
return sendJson(res, 200, { stash: stashRef, diff: String(text || '').slice(0, 200000) })
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
const branchAnswer = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
172
185
|
if (branchAnswer.code !== 0) {
|
|
173
186
|
return sendJson(res, 200, scmPayload({ cwd, error: 'not a git repository (or no commits yet)' }))
|
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
|
|
@@ -35,9 +36,10 @@ export function validateMessage(message) {
|
|
|
35
36
|
return trimmed
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** Stash references are `stash@{N}
|
|
39
|
+
/** Stash references are `stash@{N}` or integer indices; nothing else is accepted. */
|
|
39
40
|
export function validateStashRef(ref) {
|
|
40
|
-
|
|
41
|
+
let value = String(ref == null ? '' : ref).trim()
|
|
42
|
+
if (/^\d{1,3}$/.test(value)) value = `stash@{${value}}`
|
|
41
43
|
if (!/^stash@\{\d{1,3}\}$/.test(value)) throw new Error(`refusing an unsafe stash reference: "${value.slice(0, 40)}"`)
|
|
42
44
|
return value
|
|
43
45
|
}
|
|
@@ -91,11 +93,17 @@ export function buildAction(action, args = {}) {
|
|
|
91
93
|
return result(name, steps, destructive, 'save the working copy to a stash')
|
|
92
94
|
}
|
|
93
95
|
case 'stashApply':
|
|
96
|
+
case 'stash_apply':
|
|
94
97
|
return result(name, [['stash', 'apply', validateStashRef(args.ref)]], destructive, `apply ${args.ref}`)
|
|
95
98
|
case 'stashPop':
|
|
99
|
+
case 'stash_pop':
|
|
96
100
|
return result(name, [['stash', 'pop', validateStashRef(args.ref)]], destructive, `apply and drop ${args.ref}`)
|
|
97
101
|
case 'stashDrop':
|
|
102
|
+
case 'stash_drop':
|
|
98
103
|
return result(name, [['stash', 'drop', validateStashRef(args.ref)]], destructive, `drop ${args.ref}`)
|
|
104
|
+
case 'stashShow':
|
|
105
|
+
case 'stash_show':
|
|
106
|
+
return result(name, [['stash', 'show', '-p', validateStashRef(args.ref)]], destructive, `show diff of ${args.ref}`)
|
|
99
107
|
case 'continue': {
|
|
100
108
|
const verb = String(args.verb || '').trim()
|
|
101
109
|
if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to continue an unknown operation: "${verb.slice(0, 20)}"`)
|
|
@@ -135,7 +143,7 @@ export function buildAction(action, args = {}) {
|
|
|
135
143
|
}
|
|
136
144
|
}
|
|
137
145
|
|
|
138
|
-
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'abort', 'worktreeRemove'])
|
|
146
|
+
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove'])
|
|
139
147
|
|
|
140
148
|
function result(action, steps, destructive, summary) {
|
|
141
149
|
return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
|
|
@@ -145,7 +153,7 @@ function result(action, steps, destructive, summary) {
|
|
|
145
153
|
export const ACTIONS = Object.freeze([
|
|
146
154
|
'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
|
|
147
155
|
'branchCreate', 'branchCheckout', 'branchDelete',
|
|
148
|
-
'stashPush', 'stashApply', 'stashPop', 'stashDrop',
|
|
156
|
+
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow',
|
|
149
157
|
'worktreeAdd', 'worktreeRemove',
|
|
150
158
|
'continue', 'abort',
|
|
151
159
|
])
|
|
@@ -172,5 +180,16 @@ export function validateCwd(path) {
|
|
|
172
180
|
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
173
181
|
}
|
|
174
182
|
}
|
|
183
|
+
let realPath = value
|
|
184
|
+
try {
|
|
185
|
+
realPath = realpathSync(value)
|
|
186
|
+
} catch {
|
|
187
|
+
realPath = value
|
|
188
|
+
}
|
|
189
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
190
|
+
if (realPath === root || realPath.startsWith(root + '/')) {
|
|
191
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
175
194
|
return value
|
|
176
195
|
}
|
package/lib/status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { auditTokenScopes } from './token-audit.js'
|
|
1
2
|
// What the settings card is allowed to know.
|
|
2
3
|
//
|
|
3
4
|
// The status of the access — where it comes from, which account, what the rate limit looks
|
|
@@ -14,8 +15,11 @@ function isTrustedHost(host) {
|
|
|
14
15
|
if (hostname.startsWith('[')) {
|
|
15
16
|
const end = hostname.indexOf(']')
|
|
16
17
|
if (end > 0) hostname = hostname.slice(1, end)
|
|
17
|
-
} else {
|
|
18
|
-
|
|
18
|
+
} else if (hostname.includes(':')) {
|
|
19
|
+
const colons = hostname.split(':').length - 1
|
|
20
|
+
if (colons === 1) {
|
|
21
|
+
hostname = hostname.split(':')[0]
|
|
22
|
+
}
|
|
19
23
|
}
|
|
20
24
|
hostname = hostname.toLowerCase()
|
|
21
25
|
if (!hostname) return false
|
|
@@ -43,6 +47,9 @@ export function isTrustedRequest(req) {
|
|
|
43
47
|
const cleanIp = address.replace(/^::ffff:/, '')
|
|
44
48
|
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
45
49
|
|
|
50
|
+
// If socket remoteAddress is present, it MUST be a trusted local/private IP (never an untrusted remote IP)
|
|
51
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
52
|
+
|
|
46
53
|
const host = header(req, 'host')
|
|
47
54
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
48
55
|
|
|
@@ -80,6 +87,12 @@ export function isTrustedWriteRequest(req) {
|
|
|
80
87
|
return false
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
91
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
92
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
93
|
+
|
|
94
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
95
|
+
|
|
83
96
|
const host = header(req, 'host')
|
|
84
97
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
85
98
|
if (origin) {
|
|
@@ -91,9 +104,6 @@ export function isTrustedWriteRequest(req) {
|
|
|
91
104
|
}
|
|
92
105
|
} else {
|
|
93
106
|
// 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
107
|
if (!loopback) return false
|
|
98
108
|
}
|
|
99
109
|
|
|
@@ -117,12 +127,14 @@ function header(req, name) {
|
|
|
117
127
|
*/
|
|
118
128
|
export function statusPayload({ access, identity, rateLimit, scopes, cache, error } = {}) {
|
|
119
129
|
const configured = Boolean(access && access.value)
|
|
130
|
+
const audit = auditTokenScopes(scopes)
|
|
120
131
|
return {
|
|
121
132
|
configured,
|
|
122
133
|
source: configured ? access.source || '' : '',
|
|
123
134
|
login: (identity && identity.login) || '',
|
|
124
135
|
name: (identity && identity.name) || '',
|
|
125
136
|
scopes: scopes || '',
|
|
137
|
+
scopeWarnings: audit.warnings,
|
|
126
138
|
rateLimit: rateLimit
|
|
127
139
|
? { limit: rateLimit.limit, remaining: rateLimit.remaining, resetAt: rateLimit.resetAt }
|
|
128
140
|
: null,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Token scopes audit and capability diagnostics.
|
|
2
|
+
// Compares GitHub x-oauth-scopes against features of dsh-github-ops.
|
|
3
|
+
|
|
4
|
+
export const CAPABILITY_REQUIREMENTS = [
|
|
5
|
+
{
|
|
6
|
+
capability: 'Repositories & Code (Private)',
|
|
7
|
+
requiredAny: ['repo'],
|
|
8
|
+
impact: 'Access to private repositories, code pushes, branches, releases, issues and PRs',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
capability: 'Repositories & Code (Public)',
|
|
12
|
+
requiredAny: ['repo', 'public_repo'],
|
|
13
|
+
impact: 'Access to public repository code, issues, releases and PRs',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
capability: 'Workflows & CI Runs',
|
|
17
|
+
requiredAny: ['workflow'],
|
|
18
|
+
impact: 'Triggering workflow runs, cancelling runs and updating .github/workflows',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
capability: 'GitHub Packages',
|
|
22
|
+
requiredAny: ['write:packages', 'read:packages'],
|
|
23
|
+
impact: 'Publishing and downloading package bundles from ghcr.io / npm.pkg.github.com',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
capability: 'Organization & Teams',
|
|
27
|
+
requiredAny: ['read:org', 'admin:org'],
|
|
28
|
+
impact: 'Listing organization repositories, teams and membership',
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export function auditTokenScopes(rawScopes) {
|
|
33
|
+
const scopesStr = typeof rawScopes === 'string' ? rawScopes.trim() : ''
|
|
34
|
+
if (!scopesStr) {
|
|
35
|
+
return {
|
|
36
|
+
scopes: [],
|
|
37
|
+
raw: '',
|
|
38
|
+
isFineGrained: true,
|
|
39
|
+
capabilities: [],
|
|
40
|
+
missing: [],
|
|
41
|
+
warnings: ['Token does not report OAuth scopes (typical for fine-grained Personal Access Tokens or GitHub Apps). Permissions are scoped per-repository.'],
|
|
42
|
+
hasFullRepoAccess: false,
|
|
43
|
+
hasWorkflowAccess: false,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scopeList = scopesStr.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
48
|
+
const scopeSet = new Set(scopeList)
|
|
49
|
+
|
|
50
|
+
const capabilities = []
|
|
51
|
+
const missing = []
|
|
52
|
+
const warnings = []
|
|
53
|
+
|
|
54
|
+
for (const item of CAPABILITY_REQUIREMENTS) {
|
|
55
|
+
const granted = item.requiredAny.some((s) => scopeSet.has(s))
|
|
56
|
+
capabilities.push({
|
|
57
|
+
capability: item.capability,
|
|
58
|
+
granted,
|
|
59
|
+
requiredAny: item.requiredAny,
|
|
60
|
+
})
|
|
61
|
+
if (!granted) {
|
|
62
|
+
missing.push({
|
|
63
|
+
capability: item.capability,
|
|
64
|
+
requiredAny: item.requiredAny,
|
|
65
|
+
impact: item.impact,
|
|
66
|
+
})
|
|
67
|
+
warnings.push(`Missing scope [${item.requiredAny.join(' or ')}] for ${item.capability}: ${item.impact}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hasFullRepoAccess = scopeSet.has('repo')
|
|
72
|
+
const hasWorkflowAccess = scopeSet.has('workflow')
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
scopes: scopeList,
|
|
76
|
+
raw: scopesStr,
|
|
77
|
+
isFineGrained: false,
|
|
78
|
+
capabilities,
|
|
79
|
+
missing,
|
|
80
|
+
warnings,
|
|
81
|
+
hasFullRepoAccess,
|
|
82
|
+
hasWorkflowAccess,
|
|
83
|
+
}
|
|
84
|
+
}
|
package/lib/tools/actions.js
CHANGED
|
@@ -175,11 +175,13 @@ export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxL
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
const lines = cleanLogContent(logText, maxLines)
|
|
178
|
+
const errorHighlights = lines.filter((l) => /\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(l)).slice(-10)
|
|
178
179
|
return {
|
|
179
180
|
jobId: targetJobId,
|
|
180
181
|
jobName: targetJobName,
|
|
181
182
|
failedStep,
|
|
182
183
|
lineCount: lines.length,
|
|
183
184
|
lines,
|
|
185
|
+
errorHighlights,
|
|
184
186
|
}
|
|
185
187
|
}
|
package/lib/tools/mirror.js
CHANGED
|
@@ -162,7 +162,9 @@ export async function publishMirror({
|
|
|
162
162
|
|
|
163
163
|
if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
|
|
164
164
|
|
|
165
|
-
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`)
|
|
166
168
|
const env = { GIT_INDEX_FILE: indexFile }
|
|
167
169
|
try {
|
|
168
170
|
await git(['read-tree', '--empty'], { cwd, env })
|
|
@@ -190,5 +192,6 @@ export async function publishMirror({
|
|
|
190
192
|
}
|
|
191
193
|
} finally {
|
|
192
194
|
await git(['update-index', '--refresh'], { cwd, env }).catch(() => {})
|
|
195
|
+
await fs.rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {})
|
|
193
196
|
}
|
|
194
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.
|
|
@@ -255,10 +256,10 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd
|
|
|
255
256
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
256
257
|
return generateReleaseNotes(await client(), {
|
|
257
258
|
owner, repo,
|
|
258
|
-
tag: args.tag
|
|
259
|
-
target: args.target
|
|
260
|
-
previousTag: args.previousTag
|
|
261
|
-
configFile: args.configFile
|
|
259
|
+
tag: args.tag,
|
|
260
|
+
target: args.target,
|
|
261
|
+
previousTag: args.previousTag,
|
|
262
|
+
configFile: args.configFile,
|
|
262
263
|
})
|
|
263
264
|
},
|
|
264
265
|
)
|
|
@@ -337,16 +338,23 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd
|
|
|
337
338
|
|
|
338
339
|
tool(
|
|
339
340
|
'gh_release_preflight',
|
|
340
|
-
'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.',
|
|
341
342
|
{
|
|
342
343
|
cwd: { type: 'string', description: 'Directory to audit (defaults to workspace or process.cwd()).' },
|
|
343
|
-
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).' },
|
|
344
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.' },
|
|
345
347
|
},
|
|
346
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)
|
|
347
354
|
return runReleasePreflight({
|
|
348
|
-
cwd:
|
|
355
|
+
cwd: validatedCwd,
|
|
349
356
|
runTests: args.runTests,
|
|
357
|
+
confirm: args.confirm,
|
|
350
358
|
maxFileSize: args.maxFileSize,
|
|
351
359
|
})
|
|
352
360
|
},
|
|
@@ -102,6 +102,19 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
102
102
|
async (args) => withRepo(args, async ({ owner, repo }) => getRunLogsUrl(await client(), { owner, repo, runId: args.runId })),
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
tool(
|
|
106
|
+
'gh_run_failure_inspector',
|
|
107
|
+
'Inspect failed jobs and steps in a GitHub Actions workflow run, downloading only the relevant job log text, stripping ANSI escapes, redacting tokens, and returning the diagnostic error lines without downloading full zip archives. Read-only.',
|
|
108
|
+
repoParams({
|
|
109
|
+
runId: { type: 'number', description: 'Workflow run id (e.g. from gh_run_list).' },
|
|
110
|
+
jobId: { type: 'number', description: 'Specific job id to inspect (optional, defaults to first failed job).' },
|
|
111
|
+
maxLines: { type: 'number', description: 'Maximum log lines to return (default 100, max 250).' },
|
|
112
|
+
}),
|
|
113
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getJobLogSummary(await client(), {
|
|
114
|
+
owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
|
|
115
|
+
})),
|
|
116
|
+
)
|
|
117
|
+
|
|
105
118
|
tool(
|
|
106
119
|
'gh_run_dispatch',
|
|
107
120
|
'Trigger a workflow_dispatch event for a GitHub Actions workflow with inputs. Requires confirm: true.',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { auditTokenScopes } from '../token-audit.js'
|
|
1
2
|
// Content and account tools: writing files without a checkout, and signing in without a
|
|
2
3
|
// terminal.
|
|
3
4
|
//
|
|
@@ -210,7 +211,6 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
210
211
|
'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
212
|
{},
|
|
212
213
|
async () => {
|
|
213
|
-
const cfg = liveConfig()
|
|
214
214
|
const access = await auth.resolve()
|
|
215
215
|
if (!access.value) return { configured: false, guidance: access.guidance }
|
|
216
216
|
const github = await client()
|
|
@@ -227,6 +227,28 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
227
227
|
},
|
|
228
228
|
)
|
|
229
229
|
|
|
230
|
+
tool(
|
|
231
|
+
'gh_token_audit',
|
|
232
|
+
'Audit OAuth token scopes and report granted vs missing capabilities (repo, workflow, packages, org) with diagnostic warnings. Read-only.',
|
|
233
|
+
{},
|
|
234
|
+
async () => {
|
|
235
|
+
const access = await auth.resolve()
|
|
236
|
+
if (!access.value) return { configured: false, guidance: access.guidance }
|
|
237
|
+
const github = await client()
|
|
238
|
+
const { data, rateLimit } = await github.get('/user')
|
|
239
|
+
const rawScopes = github.lastScopes ? github.lastScopes() : ''
|
|
240
|
+
const audit = auditTokenScopes(rawScopes)
|
|
241
|
+
return {
|
|
242
|
+
configured: true,
|
|
243
|
+
source: access.source,
|
|
244
|
+
login: data && data.login,
|
|
245
|
+
name: data && data.name,
|
|
246
|
+
rateLimit: rateLimit || undefined,
|
|
247
|
+
...audit,
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
230
252
|
tool(
|
|
231
253
|
'gh_auth_logout',
|
|
232
254
|
'Forget the sign-in stored by this plugin (gh_auth_login). Other sources (a DSH credential, the environment, the gh CLI) are untouched. Requires confirm: true.',
|
|
@@ -240,6 +262,4 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
240
262
|
},
|
|
241
263
|
)
|
|
242
264
|
|
|
243
|
-
// keep the imports honest for callers that compose their own flows
|
|
244
|
-
return { accessFilePath, readAccessFile }
|
|
245
265
|
}
|
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|