From 752cc4105d4bec16f7f068fea52a57f2192091f5 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 17 Oct 2026 15:00:29 -0400 Subject: [PATCH 1/3] fix(coding-agent): harden publication security - Restrict model publication to trusted main sources and isolate upload credentials - Disable unsafe issue analysis and update vulnerable production dependencies - Require an explicit retained-artifact audit before changing source visibility --- .github/workflows/issue-analysis.yml | 634 +----------------- .github/workflows/publish-model-catalog.yml | 9 +- README.md | 4 +- package-lock.json | 20 +- .../install-lock/package-lock.json | 14 +- packages/coding-agent/npm-shrinkwrap.json | 14 +- packages/coding-agent/package.json | 2 +- scripts/issue-analysis.test.mjs | 95 +-- scripts/model-publication-security.test.mjs | 46 ++ 9 files changed, 96 insertions(+), 742 deletions(-) create mode 100644 scripts/model-publication-security.test.mjs diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml index 7c81db931..a51443747 100644 --- a/.github/workflows/issue-analysis.yml +++ b/.github/workflows/issue-analysis.yml @@ -1,634 +1,16 @@ -# Runs the repo's /is prompt against an issue when the `diffex-analyze` label is added -# or when a repository maintainer comments `@issuron analyze` anywhere on an issue. -# -# Comment triggers can include one `#run-on-*` tag anywhere in the text: -# @issuron analyze #run-on-linux -> ubuntu-latest (default) -# @issuron analyze #run-on-windows -> windows-latest -# @issuron analyze #run-on-mac -> macos-latest -# -# Label triggers always run on the default Linux runner. Runner selection is -# intentionally restricted to hardcoded aliases in the authorization step. -# -# Setup required before this works: -# 1. Create a `diffex-analyze` GitHub environment on the repo and add a -# `DIFFEX_AUTH_JSON` secret containing the contents of a Diffex auth.json -# (~/.diffex/agent/auth.json). -# 2. Create the `diffex-analyze` label. -# 3. Authorization uses the repository token to require write or admin permission. -# 4. Add an environment secret `DIFFEX_GIST_TOKEN` on `diffex-analyze` with gist -# creation permission. The analysis job uses it to upload the exported -# session gist. -# 5. Add an environment secret `DIFFEX_AUTH_UPDATE_TOKEN` on `diffex-analyze` with -# permission to update this repo's environment secrets. The analysis job -# uses it to write back refreshed `DIFFEX_AUTH_JSON` contents. Fine-grained -# tokens need repository permission `Environments: read and write` on this -# repo; classic tokens need the `repo` scope. The `Secrets` permission is -# not enough: it only covers repository-level Actions secrets, while every -# `/repos/{owner}/{repo}/environments/{env}/secrets/*` endpoint is governed -# by `Environments`. Without it `gh secret set` fails with "Resource not -# accessible by personal access token" and the stored credential goes stale -# until its refresh token is rejected. -# -# `DIFFEX_AUTH_JSON` must come from a login dedicated to this workflow. OpenAI Codex -# rotates refresh tokens on every refresh, so sharing one auth.json with a -# developer machine invalidates whichever copy refreshes second, and analysis -# then fails with "OAuth refresh failed for openai-codex". -# -# The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub -# hosted runners are bootstrapped below; future self-hosted aliases should have -# those dependencies preinstalled or installable by the setup steps. -# -# The session runs in a high-entropy checkout directory so the recorded cwd is -# a unique string. Import the session into a local checkout with the -# /ir extension command (.diffex/extensions/import-repro.ts): -# diffex "/ir " - +# Disabled pending credential isolation and human review of generated output. +# Do not restore issue triggers, provider secrets, write tokens, or transcript uploads +# until untrusted issue content cannot access credentials or publish arbitrary output. name: Issue Analysis on: - issues: - types: [labeled] - issue_comment: - types: [created] - -permissions: - contents: read - issues: write + workflow_dispatch: -concurrency: - group: issue-analysis-${{ github.event.issue.number }} - cancel-in-progress: false +permissions: {} jobs: - authorize: + disabled: + if: ${{ false }} runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.verify.outputs.should_run }} - extra_instructions: ${{ steps.verify.outputs.extra_instructions }} - runs_on: ${{ steps.verify.outputs.runs_on }} - runner_os: ${{ steps.verify.outputs.runner_os }} - runner_profile: ${{ steps.verify.outputs.runner_profile }} steps: - - name: Verify sender permission - id: verify - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const ANALYZE_LABEL = 'diffex-analyze'; - const TRIGGER_RE = /@issuron\s+analyze\b/i; - const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi; - const RUNNER_PROFILES = { - linux: { runsOn: 'ubuntu-latest', os: 'linux' }, - windows: { runsOn: 'windows-latest', os: 'windows' }, - mac: { runsOn: 'macos-latest', os: 'macos' }, - }; - const RUN_ON_ALIASES = { - linux: 'linux', - ubuntu: 'linux', - 'ubuntu-latest': 'linux', - windows: 'windows', - win: 'windows', - 'windows-latest': 'windows', - mac: 'mac', - macos: 'mac', - darwin: 'mac', - 'macos-latest': 'mac', - }; - - const username = context.payload.sender.login; - let extraInstructions = ''; - let runnerProfile = 'linux'; - - core.setOutput('should_run', 'false'); - core.setOutput('extra_instructions', ''); - core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn)); - core.setOutput('runner_os', RUNNER_PROFILES.linux.os); - core.setOutput('runner_profile', runnerProfile); - - if (context.eventName === 'issues') { - if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) { - console.log('Not a diffex-analyze label event'); - return; - } - } else if (context.eventName === 'issue_comment') { - if (context.payload.issue.pull_request) { - console.log('Ignoring pull request comment'); - return; - } - const body = context.payload.comment.body || ''; - if (!TRIGGER_RE.test(body)) { - console.log('Comment does not contain an @issuron analyze trigger'); - return; - } - - const resolvedProfiles = new Set(); - const unknownTags = []; - for (const match of body.matchAll(RUN_ON_TAG_RE)) { - const tag = match[1].toLowerCase(); - const resolved = RUN_ON_ALIASES[tag]; - if (!resolved) { - unknownTags.push(tag); - } else { - resolvedProfiles.add(resolved); - } - } - - if (unknownTags.length > 0) { - core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`); - return; - } - if (resolvedProfiles.size > 1) { - core.setFailed( - `Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles) - .map((profile) => `#run-on-${profile}`) - .join(', ')}`, - ); - return; - } - - runnerProfile = Array.from(resolvedProfiles)[0] || 'linux'; - extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim(); - } else { - console.log(`Unsupported event: ${context.eventName}`); - return; - } - - async function removeTriggerLabel() { - if (context.eventName !== 'issues') return; - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: ANALYZE_LABEL, - }); - } catch (error) { - if (error.status !== 404) throw error; - } - } - - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username, - }); - if (!['admin', 'write'].includes(data.permission)) { - await removeTriggerLabel(); - core.setFailed(`@${username} requires repository write or admin permission to trigger issue analysis.`); - return; - } - } catch { - await removeTriggerLabel(); - core.setFailed('Could not verify repository permission; refusing to run issue analysis.'); - return; - } - - if (context.eventName === 'issue_comment') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: [ANALYZE_LABEL], - }); - } - - const profile = RUNNER_PROFILES[runnerProfile]; - console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`); - core.setOutput('should_run', 'true'); - core.setOutput('extra_instructions', extraInstructions); - core.setOutput('runs_on', JSON.stringify(profile.runsOn)); - core.setOutput('runner_os', profile.os); - core.setOutput('runner_profile', runnerProfile); - - analyze: - needs: authorize - if: needs.authorize.outputs.should_run == 'true' - runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }} - environment: diffex-analyze - timeout-minutes: 45 - concurrency: - group: issue-analysis-diffex-auth - cancel-in-progress: false - env: - ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5 - ISSUE_ANALYSIS_THINKING: high - steps: - - name: Create high-entropy working directory name - id: workdir - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const crypto = require('crypto'); - core.setOutput('name', `diffex-ci-${crypto.randomBytes(16).toString('hex')}`); - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: ${{ steps.workdir.outputs.name }} - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json - - - name: Install system dependencies (Linux) - if: needs.authorize.outputs.runner_os == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y fd-find ripgrep - sudo ln -sf "$(which fdfind)" /usr/local/bin/fd - - - name: Install system dependencies (macOS) - if: needs.authorize.outputs.runner_os == 'macos' - run: | - if ! command -v fd >/dev/null 2>&1; then - brew install fd - fi - if ! command -v rg >/dev/null 2>&1; then - brew install ripgrep - fi - - - name: Install system dependencies (Windows) - if: needs.authorize.outputs.runner_os == 'windows' - shell: pwsh - run: | - $packages = @() - if (-not (Get-Command fd -ErrorAction SilentlyContinue)) { - $packages += "fd" - } - if (-not (Get-Command rg -ErrorAction SilentlyContinue)) { - $packages += "ripgrep" - } - if ($packages.Count -gt 0) { - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them." - } - choco install $packages -y --no-progress - } - fd --version - rg --version - - - name: Install dependencies - working-directory: ${{ steps.workdir.outputs.name }} - run: npm ci --ignore-scripts - - - name: Build - working-directory: ${{ steps.workdir.outputs.name }} - run: npm run build - - - name: Write auth.json - id: write_auth - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - DIFFEX_AUTH_JSON: ${{ secrets.DIFFEX_AUTH_JSON }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const authJson = process.env.DIFFEX_AUTH_JSON; - if (!authJson) { - throw new Error('DIFFEX_AUTH_JSON secret is not configured for the diffex-analyze environment'); - } - - const agentDir = path.join(process.env.RUNNER_TEMP, 'diffex-agent'); - fs.mkdirSync(agentDir, { recursive: true }); - const authPath = path.join(agentDir, 'auth.json'); - fs.writeFileSync(authPath, authJson, { mode: 0o600 }); - if (process.platform !== 'win32') { - fs.chmodSync(authPath, 0o600); - } - - - name: Run Diffex /is - id: run_diffex - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - DIFFEX_CODING_AGENT_DIR: ${{ runner.temp }}/diffex-agent - GH_TOKEN: ${{ github.token }} - ISSUE_URL: ${{ github.event.issue.html_url }} - EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }} - WORKDIR: ${{ steps.workdir.outputs.name }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); - const outDir = path.join(process.env.RUNNER_TEMP, 'diffex-out'); - const sessionDir = path.join(outDir, 'session'); - fs.mkdirSync(sessionDir, { recursive: true }); - - let prompt = `/is ${process.env.ISSUE_URL}`; - if (process.env.EXTRA_INSTRUCTIONS) { - prompt += '\n\nAdditional instructions from @issuron analyze comment:\n'; - prompt += process.env.EXTRA_INSTRUCTIONS; - } - - const outputPath = path.join(outDir, 'output.md'); - const output = fs.createWriteStream(outputPath); - const args = [ - 'packages/coding-agent/src/cli.ts', - '-p', - '--approve', - '--session-dir', - sessionDir, - '--model', - process.env.ISSUE_ANALYSIS_MODEL, - '--thinking', - process.env.ISSUE_ANALYSIS_THINKING, - prompt, - ]; - - const exitCode = await new Promise((resolve, reject) => { - const child = spawn('node', args, { - cwd: workdir, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.on('data', (chunk) => { - process.stdout.write(chunk); - output.write(chunk); - }); - child.stderr.on('data', (chunk) => { - process.stderr.write(chunk); - }); - child.on('error', reject); - child.on('close', resolve); - }); - await new Promise((resolve) => output.end(resolve)); - - if (exitCode !== 0) { - throw new Error(`Diffex /is failed with exit code ${exitCode}`); - } - - - name: Persist refreshed auth.json - if: always() && steps.write_auth.outcome == 'success' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GH_TOKEN: ${{ secrets.DIFFEX_AUTH_UPDATE_TOKEN }} - DIFFEX_AUTH_JSON: ${{ secrets.DIFFEX_AUTH_JSON }} - DIFFEX_CODING_AGENT_DIR: ${{ runner.temp }}/diffex-agent - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - if (!process.env.GH_TOKEN) { - throw new Error('DIFFEX_AUTH_UPDATE_TOKEN is not configured for the diffex-analyze environment'); - } - - const authPath = path.join(process.env.DIFFEX_CODING_AGENT_DIR, 'auth.json'); - if (!fs.existsSync(authPath)) { - core.warning('auth.json was not created; skipping auth persistence'); - return; - } - - const authJson = fs.readFileSync(authPath, 'utf8'); - let parsed; - try { - parsed = JSON.parse(authJson); - } catch (error) { - throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`); - } - - const codexAuth = parsed['openai-codex']; - if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) { - throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials'); - } - - if (authJson === process.env.DIFFEX_AUTH_JSON) { - console.log('auth.json is unchanged; skipping secret update'); - return; - } - - await new Promise((resolve, reject) => { - const child = spawn( - 'gh', - ['secret', 'set', 'DIFFEX_AUTH_JSON', '--env', 'diffex-analyze', '--repo', process.env.GITHUB_REPOSITORY], - { env: process.env, stdio: ['pipe', 'inherit', 'pipe'] }, - ); - const stderrChunks = []; - child.stderr.on('data', (chunk) => { - stderrChunks.push(chunk); - process.stderr.write(chunk); - }); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) { - resolve(); - return; - } - const details = Buffer.concat(stderrChunks).toString().trim(); - reject( - new Error( - `gh secret set failed with exit code ${code}${details ? `: ${details}` : ''}. ` + - 'DIFFEX_AUTH_UPDATE_TOKEN needs repository permission "Environments: read and write" on this repo.', - ), - ); - }); - child.stdin.end(authJson); - }); - - - name: Export session files - id: export_session_files - if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - DIFFEX_CODING_AGENT_DIR: ${{ runner.temp }}/diffex-agent - WORKDIR: ${{ steps.workdir.outputs.name }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - function findFirstJsonl(dir) { - if (!fs.existsSync(dir)) return undefined; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const nested = findFirstJsonl(entryPath); - if (nested) return nested; - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - return entryPath; - } - } - return undefined; - } - - const outDir = path.join(process.env.RUNNER_TEMP, 'diffex-out'); - const sessionFile = findFirstJsonl(path.join(outDir, 'session')); - if (!sessionFile) { - throw new Error('No session jsonl file found'); - } - - const sessionJsonl = path.join(outDir, 'session.jsonl'); - const sessionHtml = path.join(outDir, 'session.html'); - fs.copyFileSync(sessionFile, sessionJsonl); - - const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); - const exitCode = await new Promise((resolve, reject) => { - const child = spawn( - 'node', - ['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml], - { cwd: workdir, env: process.env, stdio: 'inherit' }, - ); - child.on('error', reject); - child.on('close', resolve); - }); - if (exitCode !== 0) { - throw new Error(`session export failed with exit code ${exitCode}`); - } - - - name: Upload session gist - id: gist - if: always() && steps.export_session_files.outcome == 'success' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - DIFFEX_GIST_TOKEN: ${{ secrets.DIFFEX_GIST_TOKEN }} - with: - github-token: ${{ secrets.DIFFEX_GIST_TOKEN }} - script: | - const fs = require('fs'); - const path = require('path'); - - if (!process.env.DIFFEX_GIST_TOKEN) { - throw new Error('DIFFEX_GIST_TOKEN is not configured'); - } - - const outDir = path.join(process.env.RUNNER_TEMP, 'diffex-out'); - const files = {}; - for (const filename of ['session.html', 'session.jsonl']) { - files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') }; - } - - const response = await github.rest.gists.create({ - public: false, - files, - }); - const gistUrl = response.data.html_url; - const gistId = response.data.id; - core.setOutput('url', gistUrl); - core.setOutput('id', gistId); - core.setOutput('share_url', gistUrl); - - - name: Comment with session import instructions - if: always() && steps.run_diffex.outcome == 'success' && steps.gist.outcome == 'success' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GIST_URL: ${{ steps.gist.outputs.url }} - GIST_ID: ${{ steps.gist.outputs.id }} - SHARE_URL: ${{ steps.gist.outputs.share_url }} - SESSION_JSONL: ${{ runner.temp }}/diffex-out/session.jsonl - with: - script: | - const fs = require('fs'); - - function extractLastAgentMessage(sessionPath) { - const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean); - let lastText = ''; - - for (const line of lines) { - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - - if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue; - - const content = entry.message.content; - const parts = []; - if (typeof content === 'string') { - parts.push(content); - } else if (Array.isArray(content)) { - for (const block of content) { - if (block?.type === 'text' && typeof block.text === 'string') { - parts.push(block.text); - } - } - } - - const text = parts.join('\n\n').trim(); - if (text) lastText = text; - } - - if (!lastText) return '_No assistant output found._'; - const maxLength = 55000; - if (lastText.length <= maxLength) return lastText; - return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`; - } - - const gistId = process.env.GIST_ID; - const shareUrl = process.env.SHARE_URL || process.env.GIST_URL; - const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL); - const body = [ - 'Diffex issue analysis finished.', - '', - `Session Gist: ${shareUrl}`, - '', - 'Continue locally from a checkout with:', - '', - '```sh', - `diffex "/ir ${gistId}"`, - '```', - '', - '
', - 'Agent analysis summary', - '', - lastAgentMessage, - '', - '
', - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - - - name: Comment on analysis failure - if: always() && steps.run_diffex.outcome == 'failure' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GIST_URL: ${{ steps.gist.outputs.url }} - SHARE_URL: ${{ steps.gist.outputs.share_url }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - script: | - const shareUrl = process.env.SHARE_URL || process.env.GIST_URL; - const lines = ['Diffex issue analysis failed; no analysis was produced.', '', `Run: ${process.env.RUN_URL}`]; - if (shareUrl) { - lines.push(`Partial session Gist: ${shareUrl}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: lines.join('\n'), - }); - - - name: Remove trigger label - if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'diffex-analyze', - }); - } catch (error) { - if (error.status !== 404) throw error; - } + - run: echo 'Issue analysis is disabled pending security redesign.' diff --git a/.github/workflows/publish-model-catalog.yml b/.github/workflows/publish-model-catalog.yml index 1d5e978ed..8a0c34143 100644 --- a/.github/workflows/publish-model-catalog.yml +++ b/.github/workflows/publish-model-catalog.yml @@ -36,7 +36,7 @@ permissions: jobs: generate: - if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }} + if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }} runs-on: ubuntu-latest env: SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} @@ -71,7 +71,7 @@ jobs: retention-days: 14 publish: - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + if: ${{ github.ref == 'refs/heads/main' && ((github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.source_ref == 'main')) }} needs: generate runs-on: ubuntu-latest environment: diffex-model-upload @@ -80,8 +80,6 @@ jobs: cancel-in-progress: true env: SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} - AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: auto AWS_EC2_METADATA_DISABLED: 'true' R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com @@ -138,6 +136,9 @@ jobs: - name: Publish model catalog to R2 if: steps.publication-window.outputs.allowed == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} run: | node scripts/publish-model-catalog.mjs \ --input .artifacts/model-catalog \ diff --git a/README.md b/README.md index defa2d4b2..8a35e9a97 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ Use `./diffex-test.sh` to run the Diffex-branded coding agent directly from this ## Distribution policy -The source repository remains private. +The source repository remains private pending an explicit publication review. +Changing visibility requires an audit of retained Actions logs, artifacts, source snapshots, debug maps, and linked session transcripts. +Artifact names such as `private-release-*` do not provide access protection. The approved release channel is the eleven public npm packages, including `@diffexai/diffex`. Standalone archives, Python, secondary package registries, and interactive CLI containers are deferred. Public packages include readable JavaScript and declarations, with Apache-2.0 metadata and complete applicable upstream notices. diff --git a/package-lock.json b/package-lock.json index 246380bb5..7a64ff3a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2735,9 +2735,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4768,9 +4768,9 @@ } }, "node_modules/shell-quote": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5137,9 +5137,9 @@ } }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -5624,7 +5624,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", - "undici": "8.9.0", + "undici": "8.12.2", "yaml": "2.9.0" }, "bin": { diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index e98550e74..01f3eec84 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -481,7 +481,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", - "undici": "8.9.0", + "undici": "8.12.2", "yaml": "2.9.0" }, "optionalDependencies": { @@ -1212,9 +1212,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1912,9 +1912,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index f5563800b..5385929c0 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -36,7 +36,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", - "undici": "8.9.0", + "undici": "8.12.2", "yaml": "2.9.0" }, "optionalDependencies": { @@ -1202,9 +1202,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1902,9 +1902,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0725d1283..ce49744b2 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -71,7 +71,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", - "undici": "8.9.0", + "undici": "8.12.2", "yaml": "2.9.0" }, "overrides": { diff --git a/scripts/issue-analysis.test.mjs b/scripts/issue-analysis.test.mjs index f688881c5..4e55dd109 100644 --- a/scripts/issue-analysis.test.mjs +++ b/scripts/issue-analysis.test.mjs @@ -1,92 +1,15 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import vm from "node:vm"; +import { parse } from "yaml"; -const workflow = readFileSync( - new URL("../.github/workflows/issue-analysis.yml", import.meta.url), - "utf8", -); -const script = workflow - .split(" script: |\n")[1] - .split("\n analyze:")[0]; +const source = readFileSync(new URL("../.github/workflows/issue-analysis.yml", import.meta.url), "utf8"); +const workflow = parse(source); -async function authorize(permission, options = {}) { - const outputs = {}; - const failures = []; - const requests = []; - const context = { - eventName: options.comment ? "issue_comment" : "issues", - repo: { owner: "diffexai", repo: "diffex" }, - issue: { number: 123 }, - payload: { - sender: { login: "reviewer" }, - action: "labeled", - label: { name: "diffex-analyze" }, - issue: {}, - comment: { body: options.body ?? "@issuron analyze" }, - }, - }; - await vm.runInNewContext(`(async () => {${script}\n})()`, { - context, - process: { env: {} }, - console: { log() {} }, - core: { - setOutput: (key, value) => { - outputs[key] = value; - }, - setFailed: (message) => failures.push(message), - }, - fetch: () => { - throw new Error("Unexpected external authorization"); - }, - github: { - rest: { - repos: { - getCollaboratorPermissionLevel: async (input) => { - requests.push(input); - if (options.failure) throw new Error("Controlled GitHub failure"); - return { data: { permission } }; - }, - }, - issues: { removeLabel: async () => {}, addLabels: async () => {} }, - }, - }, - }); - return { outputs, failures, requests }; -} - -for (const permission of ["admin", "write"]) { - test(`repository ${permission} can authorize without organization credentials`, async () => { - const result = await authorize(permission); - assert.equal(result.outputs.should_run, "true", result.failures.join("\n")); - assert.equal(result.requests[0].owner, "diffexai"); - assert.equal(result.requests[0].repo, "diffex"); - }); -} -for (const permission of ["read", "triage", "none", undefined]) { - test(`rejects ${permission} permission`, async () => { - const result = await authorize(permission); - assert.equal(result.outputs.should_run, "false"); - assert.equal(result.failures.length, 1); - }); -} -test("authorization service failures fail closed", async () => { - const result = await authorize("admin", { failure: true }); - assert.equal(result.outputs.should_run, "false"); - assert.equal(result.failures.length, 1); -}); -test("comment runner selection remains constrained", async () => { - const result = await authorize("write", { - comment: true, - body: "@issuron analyze #run-on-mac", - }); - assert.equal(result.outputs.should_run, "true"); - assert.equal(result.outputs.runner_os, "macos"); - const invalid = await authorize("write", { - comment: true, - body: "@issuron analyze #run-on-private", - }); - assert.equal(invalid.outputs.should_run, "false"); - assert.equal(invalid.requests.length, 0); +test("issue analysis cannot run tools or expose credentials", () => { + assert.deepEqual(Object.keys(workflow.on), ["workflow_dispatch"]); + assert.deepEqual(workflow.permissions, {}); + assert.deepEqual(Object.keys(workflow.jobs), ["disabled"]); + assert.equal(workflow.jobs.disabled.if, "${{ false }}"); + assert.doesNotMatch(source, /secrets\.|github\.token|--approve|gists\.create|upload-artifact/); }); diff --git a/scripts/model-publication-security.test.mjs b/scripts/model-publication-security.test.mjs new file mode 100644 index 000000000..6dedfdeda --- /dev/null +++ b/scripts/model-publication-security.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { parse } from "yaml"; + +const workflow = parse(readFileSync(new URL("../.github/workflows/publish-model-catalog.yml", import.meta.url), "utf8")); + +function allowed(job, overrides = {}, inputs = {}) { + const github = { + repository: "diffexai/diffex", + ref: "refs/heads/main", + event_name: "workflow_run", + event: { workflow_run: { + conclusion: "success", event: "push", head_branch: "main", + head_repository: { full_name: "diffexai/diffex" }, + } }, + ...overrides, + }; + return Boolean(vm.runInNewContext(workflow.jobs[job].if.replace(/^\$\{\{\s*|\s*\}\}$/g, ""), { github, inputs })); +} + +for (const job of ["generate", "publish"]) { + test(`${job} rejects untrusted workflow completions`, () => { + assert.equal(allowed(job), true); + for (const change of [ + { head_repository: { full_name: "attacker/diffex" } }, + { event: "pull_request" }, { head_branch: "feature" }, { conclusion: "failure" }, + ]) { + assert.equal(allowed(job, { event: { workflow_run: { + conclusion: "success", event: "push", head_branch: "main", + head_repository: { full_name: "diffexai/diffex" }, ...change, + } } }), false, JSON.stringify(change)); + } + }); +} + +test("publication accepts only main for scheduled and manual runs", () => { + assert.equal(allowed("publish", { event_name: "schedule" }), true); + assert.equal(allowed("publish", { event_name: "pull_request" }), false); + assert.equal(allowed("publish", { event_name: "workflow_dispatch" }, { publish: true, source_ref: "main" }), true); + for (const inputs of [{ publish: false, source_ref: "main" }, { publish: true, source_ref: "feature" }]) { + assert.equal(allowed("publish", { event_name: "workflow_dispatch" }, inputs), false); + } + assert.equal(allowed("publish", { event_name: "schedule", ref: "refs/heads/feature" }), false); +}); From 8e8ada3df4944f2d1e40da9deb246fa7defad177 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 17 Oct 2026 15:02:13 -0400 Subject: [PATCH 2/3] docs: update README overview and installation --- README.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8a35e9a97..a6fd8b708 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,28 @@ # Diffex -Diffex is a TypeScript agent harness and interactive coding agent. +Diffex is a self-evolving coding agent. It provides a multi-provider LLM API, a general-purpose agent runtime, a terminal UI, remote session protocols, and an extensible coding-agent CLI. -Diffex treats the agent harness as an optimization target instead of a static configuration. -The project is evolving the harness around observable behavior, diagnostic signals, and evidence-backed improvements. +Diffex treats the agent harness as an optimization target. +Diffex evolves through observable behavior, diagnostic signals, and evidence-backed improvements. -This repository is the current Node.js and TypeScript implementation. The archived Rust implementation is available at [diffexai/diffex-legacy](https://github.com/diffexai/diffex-legacy). Read more at [diffex.ai](https://diffex.ai/) and in the [Diffex white paper](https://diffex.ai/white-paper). -## Fork Status +Diffex builds on the [Pi Agent Harness](https://github.com/earendil-works/pi) to extend its capabilities. +Use `./diffex-test.sh` to run Diffex directly from this repository. -Diffex is a fork of the [Pi Agent Harness](https://github.com/earendil-works/pi). -The workspace packages and CLI now use Diffex-owned names. -Use `./diffex-test.sh` to run the Diffex-branded coding agent directly from this repository. +## Installation + +Diffex is released on npm as [`@diffexai/diffex`](https://www.npmjs.com/package/@diffexai/diffex?activeTab=readme). +Install it globally and launch the CLI: + +```bash +npm install -g @diffexai/diffex +diffex +``` ## Workspace Packages @@ -90,14 +96,6 @@ npm run check `npm run check` runs formatting, linting, dependency policy checks, and TypeScript validation. `./test.sh` runs the non-E2E test suites without activating tests that require provider credentials. -## Plan History - -`update_plan` saves each changed plan in the session history and returns the complete resolved plan in its tool result, including step IDs, statuses, and success criteria. -Clearing the plan returns `Plan cleared.` -Successful compaction summaries include the latest plan snapshot, or an empty plan when it was cleared or absent, so resumed and forked sessions retain the task context. -When `/tree` summarizes an abandoned branch, its saved summary identifies that branch's plan references as historical and includes the selected branch's authoritative plan snapshot. -Ordinary requests reuse the existing conversation prefix without appending a plan message to every model request. - ## Documentation - [Coding-agent documentation](packages/coding-agent/docs/index.md) From 006e464e149f16fd14e36624bd9adf98d0c29e9d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 17 Oct 2026 15:07:42 -0400 Subject: [PATCH 3/3] Revert "docs: update README overview and installation" This reverts commit 8e8ada3df4944f2d1e40da9deb246fa7defad177. --- README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a6fd8b708..8a35e9a97 100644 --- a/README.md +++ b/README.md @@ -21,28 +21,22 @@ # Diffex -Diffex is a self-evolving coding agent. +Diffex is a TypeScript agent harness and interactive coding agent. It provides a multi-provider LLM API, a general-purpose agent runtime, a terminal UI, remote session protocols, and an extensible coding-agent CLI. -Diffex treats the agent harness as an optimization target. -Diffex evolves through observable behavior, diagnostic signals, and evidence-backed improvements. +Diffex treats the agent harness as an optimization target instead of a static configuration. +The project is evolving the harness around observable behavior, diagnostic signals, and evidence-backed improvements. +This repository is the current Node.js and TypeScript implementation. The archived Rust implementation is available at [diffexai/diffex-legacy](https://github.com/diffexai/diffex-legacy). Read more at [diffex.ai](https://diffex.ai/) and in the [Diffex white paper](https://diffex.ai/white-paper). -Diffex builds on the [Pi Agent Harness](https://github.com/earendil-works/pi) to extend its capabilities. -Use `./diffex-test.sh` to run Diffex directly from this repository. +## Fork Status -## Installation - -Diffex is released on npm as [`@diffexai/diffex`](https://www.npmjs.com/package/@diffexai/diffex?activeTab=readme). -Install it globally and launch the CLI: - -```bash -npm install -g @diffexai/diffex -diffex -``` +Diffex is a fork of the [Pi Agent Harness](https://github.com/earendil-works/pi). +The workspace packages and CLI now use Diffex-owned names. +Use `./diffex-test.sh` to run the Diffex-branded coding agent directly from this repository. ## Workspace Packages @@ -96,6 +90,14 @@ npm run check `npm run check` runs formatting, linting, dependency policy checks, and TypeScript validation. `./test.sh` runs the non-E2E test suites without activating tests that require provider credentials. +## Plan History + +`update_plan` saves each changed plan in the session history and returns the complete resolved plan in its tool result, including step IDs, statuses, and success criteria. +Clearing the plan returns `Plan cleared.` +Successful compaction summaries include the latest plan snapshot, or an empty plan when it was cleared or absent, so resumed and forked sessions retain the task context. +When `/tree` summarizes an abandoned branch, its saved summary identifies that branch's plan references as historical and includes the selected branch's authoritative plan snapshot. +Ordinary requests reuse the existing conversation prefix without appending a plan message to every model request. + ## Documentation - [Coding-agent documentation](packages/coding-agent/docs/index.md)