diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf92fb..20d7cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `ci` config section: master switch, review engine (`static`/`model`), CI-driver auto-approve list, status-check name and blocking behavior, fail-on severity, poll interval, label/path filters, sensitive-path and test-existence rules, change-size caps, concurrency cap, comment posting, and report directory. - `ci.run` write action joins the `allowedActions` default list. - `requestTimeoutMs` config: hard per-request timeout that aborts the fetch when exceeded. +- Local-machine protection for the action scripts: `action-patch.mjs` and `action-post.mjs` refuse to run outside a GitHub Actions runner (no `RUNNER_TEMP` / `GITHUB_WORKSPACE`), and the new `scripts/local-test.mjs` simulates the composite action in a fully isolated sandbox — `DSH_HOME`, `DSH_PROFILE_DIR`, and the output directory are hardcoded under the system temp directory, overriding any inherited (machine-scope) `DSH_HOME`. ### Changed diff --git a/README.es.md b/README.es.md index 2f4fc1d..f635a93 100644 --- a/README.es.md +++ b/README.es.md @@ -240,6 +240,8 @@ pnpm run check:readmes # cross-checks TOC anchors, tools, and config keys in a Las pruebas simulan la API de GitHub, la CLI `gh` y git mediante runners inyectados — sin red, sin credenciales reales. `test/security.test.ts` verifica que la cadena del token nunca aparece en ninguna salida visible para el modelo o para el humano. `test/e2e.test.ts` contiene pruebas de humo optativas de la API real que se omiten automáticamente salvo que `DSH_GITHUB_E2E_TOKEN` esté definido (solo endpoints de solo lectura). +Para ejercitar la acción compuesta localmente, ejecuta `node scripts/local-test.mjs --owner-repo you/repo --pr 42` (consulta `--help` para todas las opciones). El simulador fija explícitamente `DSH_HOME`, `DSH_PROFILE_DIR`, `RUNNER_TEMP` y el directorio de salida dentro de un sandbox nuevo del directorio temporal del sistema para cada paso — tu dsh home real nunca se lee ni se escribe, incluso si existe un `DSH_HOME` de ámbito máquina — y reproduce los pasos install → prepare → run headless → post de `action.yml`. `action-patch.mjs` y `action-post.mjs` se niegan a ejecutarse fuera de un runner de GitHub Actions, de modo que la acción no puede escribir overlays de perfil ni informes en ubicaciones locales desconocidas. + ## 🗂 Estructura del repositorio ``` diff --git a/README.hi.md b/README.hi.md index 46093be..1470935 100644 --- a/README.hi.md +++ b/README.hi.md @@ -240,6 +240,8 @@ pnpm run check:readmes # cross-checks TOC anchors, tools, and config keys in a Tests injected runners के ज़रिए GitHub API, `gh` CLI, और git को mock करते हैं — कोई network नहीं, कोई real credentials नहीं। `test/security.test.ts` पुष्टि करता है कि token string किसी भी model- या human-visible output में कभी नहीं आता। `test/e2e.test.ts` में opt-in real-API smoke tests हैं जो `DSH_GITHUB_E2E_TOKEN` सेट न होने पर खुद को skip कर लेते हैं (केवल read-only endpoints)। +Composite action को locally आज़माने के लिए `node scripts/local-test.mjs --owner-repo you/repo --pr 42` चलाएँ (सभी options के लिए `--help` देखें)। Simulator हर spawned step के लिए `DSH_HOME`, `DSH_PROFILE_DIR`, `RUNNER_TEMP`, और output directory को system temp directory के एक नए sandbox में स्पष्ट रूप से fix करता है — आपका real dsh home कभी पढ़ा या लिखा नहीं जाता, चाहे machine-scope `DSH_HOME` मौजूद हो — और `action.yml` के install → prepare → headless run → post चरणों को replay करता है। `action-patch.mjs` और `action-post.mjs` GitHub Actions runner के बाहर चलने से मना कर देते हैं, इसलिए action किसी अज्ञात local location में profile overlay या report नहीं लिख सकता। + ## 🗂 रिपॉज़िटरी संरचना ``` diff --git a/README.md b/README.md index 5cbe9cf..7a574d8 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,8 @@ pnpm run check:readmes # cross-checks TOC anchors, tools, and config keys in a Tests mock the GitHub API, the `gh` CLI, and git through injected runners — no network, no real credentials. `test/security.test.ts` asserts the token string never appears in any model- or human-visible output. `test/e2e.test.ts` contains opt-in real-API smoke tests that self-skip unless `DSH_GITHUB_E2E_TOKEN` is set (read-only endpoints only; the dedicated variable keeps the unit suite hermetic). +To exercise the composite action locally, run `node scripts/local-test.mjs --owner-repo you/repo --pr 42` (see `--help` for all options). The simulator hardcodes `DSH_HOME`, `DSH_PROFILE_DIR`, `RUNNER_TEMP`, and the output directory under a fresh system-temp sandbox for every spawned step — your real dsh home is never read or written, even when a machine-scope `DSH_HOME` exists — and replays the install → prepare → headless run → post steps of `action.yml`. `action-patch.mjs` and `action-post.mjs` refuse to run outside a GitHub Actions runner, so the action cannot write profile overlays or reports into unknown local locations. + ## 🗂 Repository layout ``` diff --git a/README.pt.md b/README.pt.md index bb5cee6..c28995d 100644 --- a/README.pt.md +++ b/README.pt.md @@ -240,6 +240,8 @@ pnpm run check:readmes # cross-checks TOC anchors, tools, and config keys in a Os testes simulam a API do GitHub, a CLI `gh` e o git por meio de runners injetados — sem rede, sem credenciais reais. `test/security.test.ts` garante que a string do token nunca aparece em nenhuma saída visível ao modelo ou ao humano. `test/e2e.test.ts` contém testes de fumaça optativos da API real que se pulam automaticamente a menos que `DSH_GITHUB_E2E_TOKEN` esteja definido (apenas endpoints somente leitura). +Para exercitar a ação composta localmente, execute `node scripts/local-test.mjs --owner-repo you/repo --pr 42` (veja `--help` para todas as opções). O simulador fixa explicitamente `DSH_HOME`, `DSH_PROFILE_DIR`, `RUNNER_TEMP` e o diretório de saída em um sandbox novo do diretório temporário do sistema para cada etapa — seu dsh home real nunca é lido nem gravado, mesmo que exista um `DSH_HOME` de escopo de máquina — e reproduz os passos install → prepare → execução headless → post do `action.yml`. `action-patch.mjs` e `action-post.mjs` se recusam a executar fora de um runner do GitHub Actions, de modo que a ação não pode gravar overlays de perfil nem relatórios em locais desconhecidos. + ## 🗂 Estrutura do repositório ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 20dee86..9b22060 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -239,6 +239,8 @@ pnpm run check:readmes # 交叉检查 5 个 README 的目录锚点、工具与 测试通过注入的 runner mock 掉 GitHub API、`gh` CLI 与 git——不联网、不用真实凭证。`test/security.test.ts` 断言 token 字符串不出现在任何模型或人类可见输出中。`test/e2e.test.ts` 是可选真实 API 冒烟测试:未设置 `DSH_GITHUB_E2E_TOKEN` 时自动跳过(只打只读端点;独立变量保证单测套件与环境隔离)。 +要在本地演练 composite action,运行 `node scripts/local-test.mjs --owner-repo you/repo --pr 42`(全部选项见 `--help`)。模拟器为每个子进程显式写死 `DSH_HOME`、`DSH_PROFILE_DIR`、`RUNNER_TEMP` 与输出目录——全部位于全新的系统临时目录沙箱内,即使存在 Machine 级 `DSH_HOME` 也绝不读写你真实的 dsh home——并按 `action.yml` 的顺序回放 install → prepare → headless run → post 四步。`action-patch.mjs` 与 `action-post.mjs` 在 GitHub Actions runner 之外一律拒绝运行,因此 action 不会把 profile overlay 或报告写到未知的本地位置。 + ## 🗂 目录结构 ``` diff --git a/scripts/action-patch.mjs b/scripts/action-patch.mjs index b1346be..c609865 100644 --- a/scripts/action-patch.mjs +++ b/scripts/action-patch.mjs @@ -12,6 +12,20 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +// Local-machine protection: the composite action resolves every path from the +// runner environment (RUNNER_TEMP for the output directory, GITHUB_WORKSPACE +// for the sandbox root). Outside a GitHub Actions runner those variables do +// not exist, and every fallback would silently write the profile overlay and +// task into the current working directory while a locally spawned `dsh` +// inherits the developer's real DSH_HOME. Refuse to run instead of guessing. +const runnerTemp = (process.env.RUNNER_TEMP ?? '').trim() +const workspace = (process.env.GITHUB_WORKSPACE ?? '').trim() +if (runnerTemp === '' || workspace === '') { + console.error('dsh-github: refusing to run outside a GitHub Actions runner — both RUNNER_TEMP and GITHUB_WORKSPACE must be set.') + console.error('dsh-github: to exercise the action locally, use `node scripts/local-test.mjs`; it pins DSH_HOME, DSH_PROFILE_DIR, and the output directory under the system temp directory.') + process.exit(1) +} + const env = (name) => process.env[name] ?? '' const boolOf = (value, fallback) => value.trim() === '' ? fallback : value.trim() === 'true' || value.trim() === '1' const listOf = (value) => value.split(',').map(item => item.trim()).filter(item => item.length > 0) diff --git a/scripts/action-post.mjs b/scripts/action-post.mjs index e833ed4..c96c43b 100644 --- a/scripts/action-post.mjs +++ b/scripts/action-post.mjs @@ -11,6 +11,19 @@ import { readFileSync, appendFileSync } from 'node:fs' import { join } from 'node:path' +// Local-machine protection: the post step reads the CI result from the output +// directory and publishes step outputs through GITHUB_OUTPUT, both of which +// only exist on a GitHub Actions runner. Outside a runner the fallback would +// read an arbitrary `./dsh-github` directory next to the caller's cwd and +// report phantom verdicts. Refuse to run instead of guessing. +const runnerTemp = (process.env.RUNNER_TEMP ?? '').trim() +const workspace = (process.env.GITHUB_WORKSPACE ?? '').trim() +if (runnerTemp === '' || workspace === '') { + console.error('dsh-github: refusing to run outside a GitHub Actions runner — both RUNNER_TEMP and GITHUB_WORKSPACE must be set.') + console.error('dsh-github: to exercise the action locally, use `node scripts/local-test.mjs`; it pins DSH_HOME, DSH_PROFILE_DIR, and the output directory under the system temp directory.') + process.exit(1) +} + const outputDir = process.env.INPUT_OUTPUT_DIR?.trim() || join(process.env.RUNNER_TEMP ?? '.', 'dsh-github') const resultPath = join(outputDir, 'dsh-github-ci-result.json') const blocking = (process.env.INPUT_BLOCKING ?? 'true').trim() === 'true' diff --git a/scripts/local-test.mjs b/scripts/local-test.mjs new file mode 100644 index 0000000..9c7011f --- /dev/null +++ b/scripts/local-test.mjs @@ -0,0 +1,259 @@ +// Local simulator for the composite action — explicitly isolated. +// +// The real action resolves every path from the runner environment: +// DSH_HOME = ${{ runner.temp }}/dsh-home +// DSH_PROFILE_DIR = ${{ runner.temp }}/dsh-home/profiles/headless +// output dir = ${{ inputs.output-dir }} (default ${{ runner.temp }}/dsh-github) +// A naive local copy inherits the developer's real environment instead: a +// process-level (or machine-scope) DSH_HOME wins over everything, so the +// headless profile, sessions, storages, and reports would be written straight +// into the real dsh home — which is exactly what shuts local content down. +// +// This script therefore never reads DSH_HOME / DSH_PROFILE_DIR / RUNNER_TEMP +// from the inherited environment. Every spawned step receives hardcoded +// process-level values rooted in a fresh system-temp sandbox: +// DSH_HOME = /dsh-github-local-/dsh-home +// DSH_PROFILE_DIR = /dsh-github-local-/dsh-home/profiles/headless +// RUNNER_TEMP = /dsh-github-local- +// GITHUB_WORKSPACE = --workspace (default: this repository root) +// INPUT_OUTPUT_DIR = /dsh-github-local-/output +// +// Steps replayed, mirroring action.yml: install → prepare (action-patch.mjs) +// → headless run → post (action-post.mjs). Nothing outside the sandbox is +// written. Run `node scripts/local-test.mjs --help` for the option list. +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, delimiter, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))) + +const USAGE = `dsh-github local action simulator + +Usage: node scripts/local-test.mjs [options] + +Options: + --task CI task to simulate (default: review) + --pr Pull request number (required for review/fix-ci) + --owner-repo Repository to review (required for review/fix-ci) + --task-prompt Complete replacement for the default task text + --model Model for the headless session (default: deepseek-v4-flash) + --engine Review engine (default: static) + --check-name Status-check name (default: dsh-github-review) + --blocking Fail on needs-changes verdict (default: true) + --fail-on Lowest severity that flips the verdict (default: error) + --label-filters Comma-separated label filters + --path-filters Comma-separated path filters + --max-diff-chars Diff character cap (default: 8000) + --post-comments Post inline review comments (default: true) + --post-check Publish the status check (default: true) + --request-timeout-ms Per GitHub request timeout (default: 30000) + --plugin-version @perrylink/dsh-github version or local folder + (default: latest) + --workspace GITHUB_WORKSPACE for the sandbox (default: repo root) + --skip-install Skip the npm install of dsh-github into the profile + --skip-run Only install + generate the overlay/task (no headless run) + --clean Delete the temp sandbox when finished + +Environment (passed through from your shell, never logged): + DEEPSEEK_API_KEY Required (the action's deepseek-api-key input) + GITHUB_TOKEN | DSH_GITHUB_TOKEN Optional (the action's github-token input) + +Isolation: DSH_HOME, DSH_PROFILE_DIR, RUNNER_TEMP, and the output directory are +hardcoded under the system temp directory for every spawned step, overriding any +inherited DSH_HOME (including machine-scope values). Your real dsh home is never +read or written.` + +const args = process.argv.slice(2) +const options = { + task: 'review', + pr: '', + ownerRepo: '', + taskPrompt: '', + model: 'deepseek-v4-flash', + engine: 'static', + checkName: 'dsh-github-review', + blocking: 'true', + failOn: 'error', + labelFilters: '', + pathFilters: '', + maxDiffChars: '8000', + postComments: 'true', + postCheck: 'true', + requestTimeoutMs: '30000', + pluginVersion: 'latest', + workspace: repoRoot, + skipInstall: false, + skipRun: false, + clean: false, +} +const valueOf = { task: 1, pr: 1, 'owner-repo': 1, 'task-prompt': 1, model: 1, engine: 1, 'check-name': 1, blocking: 1, 'fail-on': 1, 'label-filters': 1, 'path-filters': 1, 'max-diff-chars': 1, 'post-comments': 1, 'post-check': 1, 'request-timeout-ms': 1, 'plugin-version': 1, workspace: 1 } +const keyOf = { task: 'task', pr: 'pr', 'owner-repo': 'ownerRepo', 'task-prompt': 'taskPrompt', model: 'model', engine: 'engine', 'check-name': 'checkName', blocking: 'blocking', 'fail-on': 'failOn', 'label-filters': 'labelFilters', 'path-filters': 'pathFilters', 'max-diff-chars': 'maxDiffChars', 'post-comments': 'postComments', 'post-check': 'postCheck', 'request-timeout-ms': 'requestTimeoutMs', 'plugin-version': 'pluginVersion', workspace: 'workspace' } +for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--help' || arg === '-h') { console.log(USAGE); process.exit(0) } + if (arg === '--skip-install') { options.skipInstall = true; continue } + if (arg === '--skip-run') { options.skipRun = true; continue } + if (arg === '--clean') { options.clean = true; continue } + const eq = arg.indexOf('=') + const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq) + if (name in valueOf) { + const inline = eq === -1 ? '' : arg.slice(eq + 1) + const value = inline !== '' ? inline : args[++i] ?? '' + options[keyOf[name]] = value + continue + } + if (arg.startsWith('-')) { + console.error(`dsh-github local-test: unknown option ${arg}`) + console.error('Run `node scripts/local-test.mjs --help` for the option list.') + process.exit(2) + } +} + +// Sandbox — hardcoded under the system temp directory, derived from nothing +// that the developer's environment could point at their real home. +const sandboxRoot = mkdtempSync(join(tmpdir(), 'dsh-github-local-')) +const dshHome = join(sandboxRoot, 'dsh-home') +const profileDir = join(dshHome, 'profiles', 'headless') +const outputDir = join(sandboxRoot, 'output') +const workspace = isAbsolute(options.workspace) ? options.workspace : resolve(options.workspace) + +console.log('dsh-github local action simulator — isolated sandbox') +console.log(` sandbox: ${sandboxRoot}`) +console.log(` DSH_HOME: ${dshHome}`) +console.log(` DSH_PROFILE_DIR:${profileDir}`) +console.log(` output dir: ${outputDir}`) +console.log(` workspace: ${workspace}`) +console.log('The real dsh home and any inherited DSH_HOME are never read or written.') + +/** Run one shell command line, inheriting the caller's environment plus explicit overrides. */ +function run(commandLine, { env = {}, allowFail = false, capture = false } = {}) { + const result = spawnSync(commandLine, { + shell: true, + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + const status = typeof result.status === 'number' ? result.status : 1 + if (!allowFail && status !== 0) { + console.error(`dsh-github local-test: command failed (exit ${status}): ${commandLine}`) + process.exit(status) + } + return { status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +/** Locate an executable (with a .cmd/.exe/.bat shim on Windows) on PATH. */ +function findOnPath(name) { + const entries = (process.env.PATH ?? '').split(delimiter) + const extensions = process.platform === 'win32' ? ['cmd', 'exe', 'bat', ''] : [''] + for (const entry of entries) { + for (const extension of extensions) { + const candidate = join(entry, extension === '' ? name : `${name}.${extension}`) + if (candidate !== name && existsSync(candidate)) return candidate + } + } + return '' +} +const q = (value) => JSON.stringify(value) + +// ---- Step 1: install the profile (action.yml "Install dsh and dsh-github") ---- +mkdirSync(profileDir, { recursive: true }) +writeFileSync(join(profileDir, 'package.json'), '{"private": true}\n') +const installEnv = { DSH_HOME: dshHome, DSH_PROFILE_DIR: profileDir } +if (options.skipInstall) { + console.log('skipping npm install (--skip-install): the overlay row @perrylink/dsh-github must resolve from the profile node_modules') +} else { + console.log(`installing @perrylink/dsh-github@${options.pluginVersion} into the sandboxed profile …`) + const target = existsSync(options.pluginVersion) || options.pluginVersion.includes('/') || options.pluginVersion.includes('\\') + ? resolve(options.pluginVersion) + : `@perrylink/dsh-github@${options.pluginVersion}` + run(`npm install --prefix ${q(profileDir)} --legacy-peer-deps --package-lock=false --no-save --no-audit --no-fund ${q(target)}`, { env: installEnv }) +} + +// ---- Step 2: prepare (action.yml "Generate the dsh profile overlay and task") ---- +mkdirSync(outputDir, { recursive: true }) +const inputs = { + INPUT_TASK: options.task, + INPUT_PR: options.pr, + INPUT_OWNER_REPO: options.ownerRepo, + INPUT_MODEL: options.model, + INPUT_ENGINE: options.engine, + INPUT_CHECK_NAME: options.checkName, + INPUT_BLOCKING: options.blocking, + INPUT_FAIL_ON: options.failOn, + INPUT_LABEL_FILTERS: options.labelFilters, + INPUT_PATH_FILTERS: options.pathFilters, + INPUT_MAX_DIFF_CHARS: options.maxDiffChars, + INPUT_POST_COMMENTS: options.postComments, + INPUT_POST_CHECK: options.postCheck, + INPUT_REQUEST_TIMEOUT_MS: options.requestTimeoutMs, + INPUT_TASK_PROMPT: options.taskPrompt, + INPUT_OUTPUT_DIR: outputDir, +} +run(`node ${q(join(repoRoot, 'scripts', 'action-patch.mjs'))}`, { + env: { RUNNER_TEMP: sandboxRoot, GITHUB_WORKSPACE: workspace, ...inputs }, +}) +console.log(`overlay and task written: ${join(outputDir, 'dsh-github-ci.cordis.yml')}`) + +if (options.skipRun) { + console.log('stopping after prepare (--skip-run); the sandbox is kept for inspection.') + console.log(`sandbox: ${sandboxRoot}`) + process.exit(0) +} + +// ---- Step 3: headless run (action.yml "Run dsh headless") ---- +if ((process.env.DEEPSEEK_API_KEY ?? '').trim() === '') { + console.error('dsh-github local-test: DEEPSEEK_API_KEY is required (the action\'s deepseek-api-key input). Set it in your shell, not in the profile.') + console.error(`sandbox kept for inspection: ${sandboxRoot}`) + process.exit(2) +} +if (findOnPath('dsh') === '') { + console.error('dsh-github local-test: the dsh CLI was not found on PATH. Install it first: npm install --global @deepseek-ai/dsh') + console.error(`sandbox kept for inspection: ${sandboxRoot}`) + process.exit(2) +} +const githubToken = process.env.DSH_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? '' +if (githubToken === '' && options.postComments === 'true') { + console.log('note: no DSH_GITHUB_TOKEN / GITHUB_TOKEN in the environment — posting comments and the status check will fail per operation (static dry inspections still work).') +} +const taskText = readFileSync(join(outputDir, 'task.txt'), 'utf8') +const overlayPath = join(outputDir, 'dsh-github-ci.cordis.yml') +const runEnv = { + DSH_HOME: dshHome, + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, + DSH_GITHUB_CI_DRIVER: '1', + DSH_GITHUB_CI_OUTPUT_DIR: outputDir, + DSH_TELEMETRY_DISABLED: '1', +} +if (githubToken !== '') runEnv.DSH_GITHUB_TOKEN = githubToken +console.log('running dsh headless in the sandboxed home …') +const headless = run(`dsh --profile headless --patch ${q(overlayPath)} ${q(taskText)}`, { env: runEnv, allowFail: true, capture: true }) +writeFileSync(join(outputDir, 'dsh-github-stdout.log'), headless.stdout) +writeFileSync(join(outputDir, 'dsh-github-stderr.log'), headless.stderr) +writeFileSync(join(outputDir, 'dsh-github-exit.txt'), `${headless.status}\n`) +console.log(headless.stdout) +if (headless.stderr.trim() !== '') console.log(headless.stderr) + +// ---- Step 4: post (action.yml "Publish outputs and enforce the gate") ---- +const post = run(`node ${q(join(repoRoot, 'scripts', 'action-post.mjs'))}`, { + env: { + RUNNER_TEMP: sandboxRoot, + GITHUB_WORKSPACE: workspace, + INPUT_OUTPUT_DIR: outputDir, + INPUT_BLOCKING: options.blocking, + GITHUB_OUTPUT: join(outputDir, 'github-output.env'), + }, + allowFail: true, +}) +console.log(`\nsandbox kept at: ${sandboxRoot}`) +console.log(` result: ${join(outputDir, 'dsh-github-ci-result.json')}`) +console.log(` summary: ${join(outputDir, 'dsh-github-ci-summary.md')}`) +console.log(` logs: ${join(outputDir, 'dsh-github-stdout.log')} / dsh-github-stderr.log`) +if (options.clean) { + rmSync(sandboxRoot, { recursive: true, force: true }) + console.log('sandbox removed (--clean).') +} +process.exit(post.status) diff --git a/test/action-guard.test.ts b/test/action-guard.test.ts new file mode 100644 index 0000000..2a35038 --- /dev/null +++ b/test/action-guard.test.ts @@ -0,0 +1,86 @@ +/** + * Local-machine protection for the action scripts (P2-7 hardening). + * + * `action-patch.mjs` and `action-post.mjs` resolve their paths from the + * GitHub Actions runner environment. Run outside a runner (RUNNER_TEMP and + * GITHUB_WORKSPACE missing — a local "simulation"), their fallbacks would + * write into the caller's working directory while a spawned `dsh` inherits + * the developer's real DSH_HOME. Both scripts must refuse to run in that + * situation, and must succeed in a clean runner-shaped environment. + * @module dsh-github/test/action-guard + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))) +const patchScript = join(repoRoot, 'scripts', 'action-patch.mjs') +const postScript = join(repoRoot, 'scripts', 'action-post.mjs') + +const sandboxes: string[] = [] + +afterEach(() => { + for (const sandbox of sandboxes.splice(0)) rmSync(sandbox, { recursive: true, force: true }) +}) + +/** A developer-shell environment: no GitHub Actions runner variables. */ +function localEnv() { + const env = { ...process.env } + for (const name of [ + 'RUNNER_TEMP', 'GITHUB_WORKSPACE', 'GITHUB_OUTPUT', + 'INPUT_OUTPUT_DIR', 'INPUT_TASK', 'INPUT_PR', 'INPUT_OWNER_REPO', + ]) delete env[name] + return env +} + +function runScript(script: string, env: Record) { + return spawnSync(process.execPath, [script], { + env, + encoding: 'utf8', + timeout: 60_000, + }) +} + +describe('action-patch.mjs local guard', () => { + it('refuses to run without RUNNER_TEMP and GITHUB_WORKSPACE', () => { + const result = runScript(patchScript, localEnv()) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to run outside a GitHub Actions runner') + }) + + it('refuses to run when RUNNER_TEMP is set but GITHUB_WORKSPACE is missing', () => { + const env = localEnv() + env.RUNNER_TEMP = join(tmpdir(), 'dsh-github-guard-') + const result = runScript(patchScript, env) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to run outside a GitHub Actions runner') + }) + + it('writes the overlay and task into INPUT_OUTPUT_DIR in a runner-shaped environment', () => { + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-github-guard-ok-')) + sandboxes.push(sandbox) + const outputDir = join(sandbox, 'output') + const result = runScript(patchScript, { + ...localEnv(), + RUNNER_TEMP: sandbox, + GITHUB_WORKSPACE: join(sandbox, 'workspace'), + INPUT_OUTPUT_DIR: outputDir, + INPUT_TASK: 'report', + INPUT_OWNER_REPO: 'o/r', + }) + expect(result.status).toBe(0) + expect(readFileSync(join(outputDir, 'dsh-github-ci.cordis.yml'), 'utf8')).toContain('dsh-github CI overlay') + expect(readFileSync(join(outputDir, 'task.txt'), 'utf8')).toContain('CI reporter') + }) +}) + +describe('action-post.mjs local guard', () => { + it('refuses to run outside a runner before reading any result file', () => { + const result = runScript(postScript, localEnv()) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to run outside a GitHub Actions runner') + }) +})