diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..e2193d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,45 @@ +name: Bug report +description: agent-security-stack did something other than what it says it does. +labels: ['bug'] +body: + - type: textarea + id: what + attributes: + label: What happened + description: What you ran, and what it did. + render: text + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What you expected instead + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Reproduction + description: > + The smallest thing that shows it. A saved page, a URL, or a local HTML fixture is ideal — attach it if you can share it. + render: text + validations: + required: true + + - type: input + id: version + attributes: + label: agent-security-stack version + placeholder: 'x.y.z' + validations: + required: true + + - type: input + id: env + attributes: + label: OS and Node version + placeholder: 'macOS 15.2, node 22.11.0' + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e98f70e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +# blank_issues_enabled stays TRUE on purpose: a template that does not fit is a +# reason to file a plain issue, not a reason to give up. The templates exist to +# make the common reports good, not to gate the uncommon ones. +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability (private) + url: https://github.com/askalf/agent-security-stack/security/advisories/new + about: > + Never file a vulnerability as a public issue. Private advisories let us + ship a fix before the details are public — see SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..eb36dd6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Something agent-security-stack should do that it does not. +labels: ['enhancement'] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: > + What are you trying to do, and where does agent-security-stack stop you? Describe + the situation rather than the feature — the best proposals here have + come from a concrete agent-browsing problem someone actually hit. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you have in mind + description: Optional. A rough shape is fine; so is "no idea, but here is the problem". + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: What you do today instead + description: The workaround, if there is one. + validations: + required: false diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..652a28f --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,68 @@ +# Path → label map for .github/workflows/pr-triage.yml (actions/labeler v7). +# +# Additive only: sync-labels is off, so nothing here ever strips a label a +# human — or Dependabot — put on a PR by hand. Add a path, get a label. +# +# Bare-noun labels, matching the Own Your Stack family vocabulary (redstamp is +# the reference), so a reader moving between repos sees the same words. + +mcp: + - changed-files: + - any-glob-to-any-file: + - 'mcp.mjs' + - 'mcp-manifest.json' + +cli: + - changed-files: + - any-glob-to-any-file: + - 'bin/**' + +audit: + - changed-files: + - any-glob-to-any-file: + - 'audit-trail.mjs' + +examples: + - changed-files: + - any-glob-to-any-file: + - 'demo/**' + - 'demo.mjs' + +support: + - changed-files: + - any-glob-to-any-file: + - 'support/**' + +lockfile: + - changed-files: + - any-glob-to-any-file: + - 'truecopy.lock' + +tests: + - changed-files: + - any-glob-to-any-file: + - 'test/**' + - '**/*.test.mjs' + +fuzz: + - changed-files: + - any-glob-to-any-file: + - 'fuzz/**' + - '.clusterfuzzlite/**' + +github_actions: + - changed-files: + - any-glob-to-any-file: + - '.github/workflows/**' + - '.github/labeler.yml' + +documentation: + - changed-files: + - any-glob-to-any-file: + - 'README.md' + - 'CONTRIBUTING.md' + - 'CODE_OF_CONDUCT.md' + - 'SECURITY.md' + - '.github/ISSUE_TEMPLATE/**' + - '.github/pull_request_template.md' + diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..8ff54a8 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1 @@ +[{"color":"5319E7","description":"Audit trail","name":"audit"},{"color":"d73a4a","description":"Something isn't working","name":"bug"},{"color":"0E8A16","description":"Command-line surface","name":"cli"},{"color":"0366d6","description":"Pull requests that update a dependency file","name":"dependencies"},{"color":"0075ca","description":"Improvements or additions to documentation","name":"documentation"},{"color":"cfd3d7","description":"This issue or pull request already exists","name":"duplicate"},{"color":"a2eeef","description":"New feature or request","name":"enhancement"},{"color":"FBCA04","description":"Demos","name":"examples"},{"color":"B60205","description":"Fuzzing and ClusterFuzzLite","name":"fuzz"},{"color":"000000","description":"Pull requests that update GitHub Actions code","name":"github_actions"},{"color":"7057ff","description":"Good for newcomers","name":"good first issue"},{"color":"008672","description":"Extra attention is needed","name":"help wanted"},{"color":"e4e669","description":"This doesn't seem right","name":"invalid"},{"color":"168700","description":"Pull requests that update javascript code","name":"javascript"},{"color":"5319E7","description":"truecopy.lock pin set","name":"lockfile"},{"color":"1D76DB","description":"MCP server and manifest","name":"mcp"},{"color":"d876e3","description":"Further information is requested","name":"question"},{"color":"F0883E","description":"200-799 hand-written lines","name":"size/L"},{"color":"FBCA04","description":"50-199 hand-written lines","name":"size/M"},{"color":"8AC926","description":"10-49 hand-written lines","name":"size/S"},{"color":"D93F0B","description":"800+ hand-written lines","name":"size/XL"},{"color":"3FB950","description":"Under 10 hand-written lines","name":"size/XS"},{"color":"8E44AD","description":"Support scripts","name":"support"},{"color":"0052CC","description":"Test suite and CI","name":"tests"},{"color":"ffffff","description":"This will not be worked on","name":"wontfix"}] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..46dc4c8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ + + +## What this changes + + + +## Why + + + +## How it was verified + + + +- [ ] `npm test` passes diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..9549a6e --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,57 @@ +name: labels + +# .github/labels.json is the repository's DECLARED label set. This job proves it +# is also the LIVE one. +# +# Without it the manifest rots: someone renames a label in the GitHub UI, the +# manifest still lists the old name, and test/labeler-config.test.mjs happily +# confirms that labeler.yml only applies declared labels — all three agreeing +# with each other and none of them with GitHub. The offline test checks +# declared-vs-applied; this checks declared-vs-live. Between them, "every label +# this repo applies actually exists" is proven rather than asserted. +# +# A standalone workflow rather than a job inside ci.yml: it is one API call with +# a read-only token, it has nothing to do with building or testing the product, +# and keeping it separate means the same file drops into every repo in the family +# regardless of what that repo's ci.yml looks like. + +on: + push: + branches: [main, master] + pull_request: + paths: + - '.github/labels.json' + - '.github/labeler.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/workflows/labels.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + manifest: + name: manifest matches the repo + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + contents: read + issues: read # gh label list + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: The declared label set is the live label set + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label list --repo "$GITHUB_REPOSITORY" --limit 200 --json name --jq '.[].name' \ + | LC_ALL=C sort > live.txt + node -e "JSON.parse(require('fs').readFileSync('.github/labels.json','utf8')).forEach(l => console.log(l.name))" \ + | LC_ALL=C sort > declared.txt + if ! diff -u declared.txt live.txt; then + echo "::error::.github/labels.json and the repository's labels differ (- declared only, + live only). Change the label on GitHub AND in the manifest together." + exit 1 + fi + echo "$(wc -l < live.txt) labels, manifest matches" diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 0000000..8ca8d77 --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,129 @@ +name: PR triage + +# Labels, sizes, assigns, and issue-links every PR so the queue reads at a +# glance instead of being a wall of untriaged titles. +# +# pull_request_target, so PRs from forks get triaged too — a plain +# pull_request run gets a read-only token on a fork PR and would just 403. +# It is the safe half of that trigger: this workflow never checks out or +# executes PR code, it only reads the event payload and calls the API. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + +permissions: + contents: read + +concurrency: + group: pr-triage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + triage: + name: triage + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Label by changed path + uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + with: + # Additive. Syncing would strip the `dependencies` label Dependabot + # puts on its own PRs whenever the diff doesn't touch a manifest. + sync-labels: false + + - name: Size label, assignee, issue link + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const number = pr.number; + + // ── size ────────────────────────────────────────────────────── + // Measured on hand-written lines only. Lockfiles, hash-pinned + // requirements and generated corpora are real changes but they + // aren't review effort, and counting them makes every one of + // them an size/XL. + const GENERATED = [ + /(^|\/)package-lock\.json$/, + /(^|\/)requirements-.*\.txt$/, + /(^|\/)go\.sum$/, + /^arena\/corpus.*\.json$/, + /^arena\/.*-results\.json$/, + ]; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: number, per_page: 100, + }); + const churn = files + .filter(f => !GENERATED.some(re => re.test(f.filename))) + .reduce((n, f) => n + f.additions + f.deletions, 0); + const SIZES = [ + ['size/XS', 10], ['size/S', 50], ['size/M', 200], + ['size/L', 800], ['size/XL', Infinity], + ]; + const size = SIZES.find(([, max]) => churn < max)[0]; + + const current = pr.labels.map(l => l.name); + for (const stale of current.filter(n => n.startsWith('size/') && n !== size)) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: number, name: stale, + }).catch(() => {}); + } + if (!current.includes(size)) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: number, labels: [size], + }); + core.info(`size: ${size} (${churn} hand-written lines)`); + } + + // ── assignee ────────────────────────────────────────────────── + // Whoever opened it owns it until someone says otherwise. Bots + // can't be assignees, and an outside contributor without repo + // access is silently dropped by the API — so this is best-effort + // and never fails the run. + if (pr.assignees.length === 0 && pr.user.type !== 'Bot') { + try { + await github.rest.issues.addAssignees({ + owner, repo, issue_number: number, assignees: [pr.user.login], + }); + core.info(`assigned: ${pr.user.login}`); + } catch (e) { + core.info(`could not assign ${pr.user.login}: ${e.message}`); + } + } + + // ── development (linked issue) ──────────────────────────────── + // The Development sidebar link is driven by a closing keyword in + // the body — there's no API for it. So if the branch names an + // issue and the body doesn't already close one, write the + // keyword in. Only explicit branch forms count (issue-42, gh-42, + // 42-some-fix); a bare digit anywhere in a branch name is a + // version or a retry counter far more often than an issue. + const body = pr.body || ''; + const closes = /\b(clos(e[sd]?)?|fix(e[sd])?|resolv(e[sd]?))\s+#\d+/i; + const branch = pr.head.ref; + const ref = branch.match(/(?:^|[/_-])(?:issue|issues|gh)[-_]?(\d+)(?:[/_-]|$)/i) + || branch.match(/^(\d+)-/); + + if (!closes.test(body) && ref) { + const issue_number = Number(ref[1]); + try { + const { data: issue } = await github.rest.issues.get({ + owner, repo, issue_number, + }); + // #N addresses both issues and PRs; only an open issue is a + // thing this PR can close. + if (!issue.pull_request && issue.state === 'open') { + await github.rest.pulls.update({ + owner, repo, pull_number: number, + body: `${body.trimEnd()}\n\nCloses #${issue_number}`.trimStart(), + }); + core.info(`linked: #${issue_number}`); + } + } catch (e) { + core.info(`no linkable issue #${issue_number}: ${e.message}`); + } + } diff --git a/test/labeler-config.test.mjs b/test/labeler-config.test.mjs new file mode 100644 index 0000000..a2d1afd --- /dev/null +++ b/test/labeler-config.test.mjs @@ -0,0 +1,115 @@ +// The labeler config is the kind of file that rots silently: a path glob that +// matches nothing still parses, still runs, and simply labels nothing forever. +// Nobody notices, because the symptom is an absence. This asserts every glob in +// .github/labeler.yml matches at least one tracked file, so moving or renaming a +// source file breaks CI instead of quietly retiring a label. +// +// Reads the YAML with a targeted regex rather than a parser: the repo ships no +// YAML dependency and this file's shape is fixed and simple. The structural +// assertions below fail loudly if that shape ever changes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const config = readFileSync(path.join(root, '.github', 'labeler.yml'), 'utf8'); + +const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }) + .split('\n') + .filter(Boolean); + +// Minimal glob → RegExp for the subset the config uses: `**` (any depth, +// including none), `*` (one segment), and literals. Order matters — `**` must be +// consumed before `*`. +function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*' && glob[i + 1] === '*') { + // `a/**` matches a/b and a/b/c; `**/x` matches x and a/x. + if (glob[i + 2] === '/') { + re += '(?:.*/)?'; + i += 2; + } else { + re += '.*'; + i += 1; + } + continue; + } + if (c === '*') { + re += '[^/]*'; + continue; + } + re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +// Every quoted glob under a `- '…'` bullet in the changed-files blocks. +const globs = [...config.matchAll(/^\s+- '([^']+)'$/gm)].map((m) => m[1]); + +test('the config actually declares globs', () => { + assert.ok(globs.length >= 10, `only found ${globs.length} globs — did the file shape change?`); +}); + +test('every glob matches at least one tracked file', () => { + const dead = globs.filter((g) => { + const re = globToRegExp(g); + return !tracked.some((f) => re.test(f)); + }); + assert.deepEqual(dead, [], `these labeler globs match nothing (moved or renamed?): ${dead.join(', ')}`); +}); + +// .github/labels.json is the repository's declared label set. A label that is +// applied but not declared gets created implicitly, with a default colour and +// no description, which is how label sets turn to mush. This file checks +// declared-vs-applied OFFLINE; CI's `labels` job checks declared-vs-live with +// `gh label list`, so the manifest cannot rot either. Between the two, "every +// applied label exists in the repo" is actually proven rather than asserted. +const manifest = JSON.parse(readFileSync(path.join(root, '.github', 'labels.json'), 'utf8')); +const declared = new Set(manifest.map((l) => l.name)); + +test('the label manifest is well-formed', () => { + assert.ok(manifest.length >= 10, `only ${manifest.length} labels declared — did the file shape change?`); + const names = manifest.map((l) => l.name); + assert.equal(new Set(names).size, names.length, 'duplicate label names in the manifest'); + // GitHub label names are unique case-insensitively: `Bug` cannot coexist with + // `bug`. Fold before checking so the manifest cannot declare a pair GitHub + // would refuse. + const folded = names.map((n) => n.toLowerCase()); + assert.equal(new Set(folded).size, folded.length, 'labels that differ only by case'); + for (const l of manifest) { + assert.match(l.color, /^[0-9a-f]{6}$/i, `label "${l.name}": colour "${l.color}" is not six hex digits`); + } +}); + +test('every label the labeler config applies is declared', () => { + const labels = [...config.matchAll(/^'?([a-z][a-z0-9 :_-]*)'?:$/gim)].map((m) => m[1].trim()); + assert.ok(labels.includes('tests'), 'expected a tests label rule'); + assert.ok(labels.length >= 5, `only ${labels.length} label rules — did the file shape change?`); + const missing = labels.filter((l) => !declared.has(l)); + assert.deepEqual(missing, [], + `labeler.yml applies labels the repo does not declare — actions/labeler would create them ad hoc: ${missing.join(', ')}`); +}); + +test('every label an issue template applies is declared', () => { + const dir = path.join(root, '.github', 'ISSUE_TEMPLATE'); + const forms = readdirSync(dir).filter((f) => f.endsWith('.yml') && f !== 'config.yml'); + // Two is the family floor: every repo ships a bug and a feature form. A repo + // whose product is a DETECTOR ships a third, the false-positive form — so this + // cannot assert three without failing on every repo that has nothing to be + // wrong about. + assert.ok(forms.length >= 2, `only ${forms.length} issue forms — did the directory shape change?`); + for (const f of forms) { + const src = readFileSync(path.join(dir, f), 'utf8'); + const m = /^labels:\s*\[([^\]]*)\]\s*$/m.exec(src); + assert.ok(m, `${f}: no top-level labels: [...] line`); + const labels = m[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean); + assert.ok(labels.length >= 1, `${f}: labels list is empty`); + const missing = labels.filter((l) => !declared.has(l)); + assert.deepEqual(missing, [], `${f} applies undeclared labels: ${missing.join(', ')}`); + } +});