diff --git a/.gitattributes b/.gitattributes index c06ed3778..6edb01a25 100644 --- a/.gitattributes +++ b/.gitattributes @@ -53,5 +53,8 @@ Dockerfile.* text eol=lf # perpetually dirty tree (#1788). *.snap text eol=lf +# Squad: the gh-aw resource probe is authenticated byte-for-byte before agent startup. +workflows/shared/squad-gh-aw-resource-probe.txt text eol=lf + # Squad: union merge for append-only team state files -.squad/fact-checker/audit-trail.md merge=union +.squad/fact-checker/audit-trail.md merge=union \ No newline at end of file diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index a7f9befba..5b3d2c7dc 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -242,6 +242,28 @@ committed. If a `.gitignore` is missing from `.github/aw/logs/`, add one there: Once pushed, the `/squad` slash command is live on your repo. +### Experimental gh-aw resource probe (`dev` test zone) + +Issue #1982 adds a gh-aw-only plaintext probe to the `@dev` dispatcher. It tests +whether a direct install delivers top-level `resources:` beside the installed +workflow. The probe does not change Cast behavior or any CLI/SDK install path; +the existing Base64 Cast validator remains authoritative. + +In a disposable test repository, install or refresh only the dispatcher: + +```bash +gh aw add bradygaster/squad/workflows/squad.md@dev --force +gh aw compile squad --strict +test -r .github/workflows/shared/squad-gh-aw-resource-probe.txt +``` + +Review the source, generated lockfile, and plaintext probe before committing the +test-zone update. Then comment `/squad cast` on a test issue. A missing, +unreadable, or modified probe fails the pre-agent assertion explicitly; a valid +probe leaves the current Cast path unchanged. After updating `@dev`, rerun the +same Cast issue to confirm the installed resource is used without changing the +expected Cast PR contents. + ### Optional: pin a CLI version Activation downloads a self-contained GitHub Release bundle; it does not install diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 1d7ef7f84..4e1aacbeb 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -9,10 +9,11 @@ */ import { afterAll, describe, it, expect } from 'vitest'; -import { cpSync, readFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { chmodSync, cpSync, readFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { minimatch } from 'minimatch'; import { POSIX_SHELL, NO_POSIX_SHELL_MESSAGE, requirePosixShell } from './posix-shell'; import { @@ -167,6 +168,40 @@ function extractImports(frontmatter: string): string[] { return imports; } +/** Extract the resources list from frontmatter. */ +function extractResources(frontmatter: string): string[] { + const resources: string[] = []; + const lines = frontmatter.split('\n'); + let inResources = false; + + for (const line of lines) { + if (line.match(/^resources:\s*$/)) { + inResources = true; + continue; + } + + if (inResources) { + const itemMatch = line.match(/^\s+-\s+(.+)$/); + if (itemMatch) { + resources.push(itemMatch[1].trim()); + } else if (line.match(/^\S/)) { + break; + } + } + } + + return resources; +} + +function extractNamedStepScript(frontmatter: string, stepName: string): string { + const escapedName = stepName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = frontmatter.match( + new RegExp(`^ - name: ${escapedName}\\n shell: bash\\n run: \\|\\n([\\s\\S]*?)(?=^ - name:|\\n\\S)`, 'm'), + ); + if (!match) throw new Error(`No bash step named "${stepName}" found`); + return match[1].split('\n').map(line => line.replace(/^ {6}/, '')).join('\n').trim(); +} + function extractWorkflowDispatchInputs(frontmatter: string): Record> { const inputs: Record> = {}; const lines = frontmatter.split('\n'); @@ -568,6 +603,111 @@ describe('gh-aw: shared component imports', () => { expect(imports.length).toBeGreaterThan(0); }); + describe('#1982: experimental gh-aw resource delivery probe', () => { + const frontmatter = extractFrontmatter(SQUAD_WORKFLOW); + const probeResource = 'shared/squad-gh-aw-resource-probe.txt'; + const probePath = join(WORKFLOWS_DIR, probeResource); + const expectedDigest = 'dba0c331c0b0fda06539bd9245dda72bd9c36cca6962726147b03168c1d97a73'; + const stepName = 'Assert experimental gh-aw resource delivery'; + + it('declares the plaintext probe on the top-level direct-install workflow only', () => { + expect(extractResources(frontmatter)).toEqual([probeResource]); + expect(existsSync(probePath)).toBe(true); + expect(readText(probePath)).toContain('Experiment: bradygaster/squad#1982'); + expect(readText(probePath)).toContain('no executable or runtime behavior'); + + for (const sharedFile of [ + 'squad.md', + 'squad-cast-validator.md', + 'squad-planning-ontology.md', + 'squad-planning-policy.md', + ]) { + expect(readText(join(SHARED_DIR, sharedFile))).not.toMatch(/^resources:/m); + } + }); + + it('pins the exact plaintext bytes and checks missing, unreadable, and mismatched resources', () => { + const actualDigest = createHash('sha256').update(readFileSync(probePath)).digest('hex'); + const script = extractNamedStepScript(frontmatter, stepName); + + expect(actualDigest).toBe(expectedDigest); + expect(script).toContain(`expected_sha256="${expectedDigest}"`); + expect(script).toContain('if [ ! -f "$probe" ]'); + expect(script).toContain('if [ ! -r "$probe" ]'); + expect(script).toContain('Experimental gh-aw resource probe SHA-256 mismatch'); + }); + + it('fails closed before the agent for a missing or modified installed probe', () => { + const script = extractNamedStepScript(frontmatter, stepName); + const bash = process.platform === 'win32' ? requirePosixShell() : 'bash'; + const workspace = createTestWorkspace('gh-aw-resource-probe-'); + const installedProbe = join(workspace, '.github', 'workflows', probeResource); + mkdirSync(dirname(installedProbe), { recursive: true }); + + const missing = spawnSync(bash, ['-c', script], { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: workspace }, + }); + expect(missing.status).not.toBe(0); + expect(missing.stderr).toContain('Experimental gh-aw resource probe is missing'); + + writeFileSync(installedProbe, 'tampered\n'); + const modified = spawnSync(bash, ['-c', script], { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: workspace }, + }); + expect(modified.status).not.toBe(0); + expect(modified.stderr).toContain('Experimental gh-aw resource probe SHA-256 mismatch'); + + cpSync(probePath, installedProbe); + const verified = spawnSync(bash, ['-c', script], { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: workspace }, + }); + expect(verified.status).toBe(0); + expect(verified.stdout).toContain('Experimental gh-aw resource delivery verified'); + }); + + it.skipIf( + process.platform === 'win32' || + typeof process.getuid !== 'function' || + process.getuid() === 0, + )('fails closed before the agent for an unreadable installed probe', () => { + const script = extractNamedStepScript(frontmatter, stepName); + const workspace = createTestWorkspace('gh-aw-resource-probe-unreadable-'); + const installedProbe = join(workspace, '.github', 'workflows', probeResource); + const bash = 'bash'; + mkdirSync(dirname(installedProbe), { recursive: true }); + cpSync(probePath, installedProbe); + + try { + chmodSync(installedProbe, 0o000); + const permissionCheck = spawnSync( + bash, + ['-c', '[ ! -r "$1" ]', 'bash', installedProbe], + { encoding: 'utf8' }, + ); + expect( + permissionCheck.status, + 'fixture must be unreadable to Bash before exercising the workflow branch', + ).toBe(0); + + const unreadable = spawnSync(bash, ['-c', script], { + cwd: workspace, + encoding: 'utf8', + env: { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: workspace }, + }); + expect(unreadable.status).not.toBe(0); + expect(unreadable.stderr).toContain('Experimental gh-aw resource probe is not readable'); + } finally { + chmodSync(installedProbe, 0o600); + } + }); + }); + it('each imported file exists in workflows/shared/', () => { for (const importPath of imports) { const fullPath = join(WORKFLOWS_DIR, importPath); @@ -947,7 +1087,10 @@ describe('gh-aw: prompt budget & planning import regression', () => { // moved from agent-transcribed prompt text into a deterministic pre-agent runner. // The runner also emits a machine-readable factual failure record. The Cast skill // shrank, and the ambient prompt remains below its independently enforced 40 KB cap. - const SOURCE_GROWTH_BUDGET_KB = 194; + // Raised 194 -> 196 KB by #1982's gh-aw-only resource assertion. The deterministic + // step runs before the agent and does not enter its prompt; the separate ambient + // prompt budget remains the authoritative delivered-context guard. + const SOURCE_GROWTH_BUDGET_KB = 196; const SOURCE_GROWTH_BUDGET_BYTES = SOURCE_GROWTH_BUDGET_KB * 1024; it('squad-planning-ontology.md is in the imports list', () => { diff --git a/workflows/shared/squad-gh-aw-resource-probe.txt b/workflows/shared/squad-gh-aw-resource-probe.txt new file mode 100644 index 000000000..90041bae8 --- /dev/null +++ b/workflows/shared/squad-gh-aw-resource-probe.txt @@ -0,0 +1,3 @@ +Squad gh-aw resource delivery probe. +Experiment: bradygaster/squad#1982 +This plaintext file has no executable or runtime behavior. diff --git a/workflows/squad.md b/workflows/squad.md index 5eb8660d9..f6259e254 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -43,6 +43,8 @@ imports: - shared/squad-cast-validator.md - shared/squad-planning-ontology.md - shared/squad-planning-policy.md +resources: + - shared/squad-gh-aw-resource-probe.txt tools: bash: true web-fetch: @@ -50,6 +52,30 @@ tools: mode: gh-proxy toolsets: [default] steps: + - name: Assert experimental gh-aw resource delivery + shell: bash + run: | + set -euo pipefail + probe="${GITHUB_WORKSPACE:?}/.github/workflows/shared/squad-gh-aw-resource-probe.txt" + expected_sha256="dba0c331c0b0fda06539bd9245dda72bd9c36cca6962726147b03168c1d97a73" + if [ ! -f "$probe" ]; then + printf 'Experimental gh-aw resource probe is missing: %s\n' "$probe" >&2 + exit 1 + fi + if [ ! -r "$probe" ]; then + printf 'Experimental gh-aw resource probe is not readable: %s\n' "$probe" >&2 + exit 1 + fi + actual_sha256="$( + node -e 'const c=require("node:crypto"),f=require("node:fs");process.stdout.write(c.createHash("sha256").update(f.readFileSync(process.argv[1])).digest("hex"))' \ + "$probe" + )" + if [ "$actual_sha256" != "$expected_sha256" ]; then + printf 'Experimental gh-aw resource probe SHA-256 mismatch: expected %s, got %s.\n' \ + "$expected_sha256" "$actual_sha256" >&2 + exit 1 + fi + printf 'Experimental gh-aw resource delivery verified.\n' - name: Prepare deterministic Cast validator runner shell: bash run: |