Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 145 additions & 2 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, Record<string, string>> {
const inputs: Record<string, Record<string, string>> = {};
const lines = frontmatter.split('\n');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions workflows/shared/squad-gh-aw-resource-probe.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Squad gh-aw resource delivery probe.
Experiment: bradygaster/squad#1982
This plaintext file has no executable or runtime behavior.
26 changes: 26 additions & 0 deletions workflows/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,39 @@ 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:
github:
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: |
Expand Down