@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 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, repoHint } = {}) {
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 || (typeof repoHint === 'function' ? repoHint() : undefined), remoteUrl: git.remoteUrl }),
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 || (typeof repoHint === 'function' ? repoHint() : undefined) }),
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 || (typeof repoHint === 'function' ? repoHint() : undefined)
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}`; nothing else is accepted. */
39
+ /** Stash references are `stash@{N}` or integer indices; nothing else is accepted. */
39
40
  export function validateStashRef(ref) {
40
- const value = String(ref == null ? '' : ref).trim()
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
- hostname = hostname.split(':')[0]
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
+ }
@@ -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
  }
@@ -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 indexFile = path.join(tmpdir(), `dsh-mirror-${process.pid}-${randomUUID()}.index`)
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 || args.pullNumber,
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 || args.pullNumber,
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 || args.tagName,
259
- target: args.target || args.targetCommitish,
260
- previousTag: args.previousTag || args.previousTagName,
261
- configFile: args.configFile || args.configurationFilePath,
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 (default false).' },
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: args.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 cwd = options.cwd || process.cwd()
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
@@ -236,4 +236,3 @@ export async function generateReleaseNotes(client, { owner, repo, tag, target, p
236
236
  previousTag: previousTag || '',
237
237
  }
238
238
  }
239
- export { runReleasePreflight } from './release-preflight.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.11",
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",