From ac3df5bf108c7068764552a705b954f40561415a Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:26:25 -0400 Subject: [PATCH 1/3] github: PR triage and issue templates, matching redstamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-security-stack labelled nothing it authored. Dependabot tagged its own PRs; everything else arrived bare, and issues had no shape to arrive in either. Ports redstamp's pr-triage.yml rather than inventing a second pattern: path labels, a size label measured on hand-written lines, self-assignment, and issue linking from the branch name. Bare-noun labels matching the family vocabulary, so a reader moving between the Own Your Stack repos meets the same words. The path map is this repo's own shape — see .github/labeler.yml. Labels say what a PR TOUCHES, never how urgent it is or whether it is ready; bug, enhancement and blocked stay judgement calls. pull_request_target, because a fork PR gets a read-only token on pull_request and labelling would silently no-op on exactly the contributions that most need triage. That event is the fork-RCE vector ONLY when a workflow checks out and runs the fork's code; this one checks out nothing. sync-labels stays false so a maintainer's manual label survives the next push. --- .github/ISSUE_TEMPLATE/bug.yml | 52 ++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 10 +++ .github/ISSUE_TEMPLATE/feature.yml | 30 +++++++ .github/labeler.yml | 68 +++++++++++++++ .github/pull_request_template.md | 23 +++++ .github/workflows/pr-triage.yml | 129 +++++++++++++++++++++++++++++ test/labeler-config.test.mjs | 75 +++++++++++++++++ 7 files changed, 387 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/labeler.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-triage.yml create mode 100644 test/labeler-config.test.mjs diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..6342c8d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,52 @@ +name: Bug report +description: agent-security-stack did something other than what it says it does. +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + For a **missed injection** or a **wrong flag**, please use the + false-positive template or the security advisory link instead — those + need different information than a crash does. + + - 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/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1354238 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## What this changes + + + +## Why + + + +## How it was verified + + + +- [ ] `npm test` +- [ ] Detection change? Verdicts before/after are stated above, on real input. +- [ ] Acceptance change? `support/watch-accept.mjs` output pasted, with a `class` and a `note` saying what was read. 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..563592a --- /dev/null +++ b/test/labeler-config.test.mjs @@ -0,0 +1,75 @@ +// 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 } 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(', ')}`); +}); + +test('labels named in the config exist in the repo label set', () => { + // Names only — a label the repo does not define gets created implicitly with + // a default colour and no description, which is how label sets turn to mush. + 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?`); + for (const l of labels) { + assert.ok(l === l.toLowerCase(), `label "${l}" is not lowercase — GitHub labels are case-sensitive`); + } +}); From 77dd09adbf9ad398deb8f3176d0c83be7b0e7bcd Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:42:22 -0400 Subject: [PATCH 2/3] review: prove the labels exist rather than claiming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redstamp's gating review caught that the label test was named "labels named in the config exist in the repo label set" while never reading the repo's labels — it parsed the config and checked lowercase, a count, and that `tests` appeared. Delete a label, or add a rule for one nobody created, and it still passed while actions/labeler quietly minted an undescribed default: the exact outcome its own comment claimed to prevent. Another session had already fixed it in redstamp, and better than my first attempt (which only narrowed the claim). This adopts that shape rather than shipping two answers to one question: .github/labels.json the declared label set, generated from the live labels labeler-config test every label labeler.yml applies is declared, and every label an ISSUE TEMPLATE applies is declared too — the half I had missed entirely .github/workflows/ declared-vs-live, one `gh label list` call with a labels.yml read-only token The offline test checks declared-vs-applied; the workflow checks declared-vs-live. Between them "every label this repo applies actually exists" is proven rather than asserted, and the manifest cannot rot in silence. Two deliberate differences from redstamp's version: - The live check is its OWN workflow, not a job inside ci.yml. It is one API call unrelated to building or testing the product, and a standalone file drops into every repo in the family regardless of what that repo's ci.yml looks like. - The issue-form count floor is 2, not 3. Every repo ships a bug and a feature form; only a repo whose product is a DETECTOR ships the third, false-positive form. Asserting 3 fails on every repo that has nothing to be wrong about — caught by running it, on four repos at once. --- .github/labels.json | 1 + .github/workflows/labels.yml | 57 ++++++++++++++++++++++++++++++++++++ test/labeler-config.test.mjs | 52 ++++++++++++++++++++++++++++---- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 .github/labels.json create mode 100644 .github/workflows/labels.yml 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/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/test/labeler-config.test.mjs b/test/labeler-config.test.mjs index 563592a..a2d1afd 100644 --- a/test/labeler-config.test.mjs +++ b/test/labeler-config.test.mjs @@ -9,7 +9,7 @@ // assertions below fail loudly if that shape ever changes. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -63,13 +63,53 @@ test('every glob matches at least one tracked file', () => { assert.deepEqual(dead, [], `these labeler globs match nothing (moved or renamed?): ${dead.join(', ')}`); }); -test('labels named in the config exist in the repo label set', () => { - // Names only — a label the repo does not define gets created implicitly with - // a default colour and no description, which is how label sets turn to mush. +// .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?`); - for (const l of labels) { - assert.ok(l === l.toLowerCase(), `label "${l}" is not lowercase — GitHub labels are case-sensitive`); + 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(', ')}`); } }); From cffb3ba31f92f628887bd7967023cc42156a5ee4 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:00:51 -0400 Subject: [PATCH 3/3] review: templates say true things about THIS repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hybrid's gating review caught that I renamed the product in the copied templates and left the content. It listed the damage precisely: a Python inference router shipped a bug form routing reporters to a false-positive template that does not exist, demanding a Node version unrelated to running it, and a PR checklist asking for `npm test` and `support/watch-accept.mjs` output in a repo with neither. Every contributor following it would be following instructions that cannot be followed. That was not one repo's slip. It was in seven of the nine PR templates and eight of the nine bug forms — find-and-replace produced files that READ fine and INSTRUCT badly, which is the failure mode of adapting a template by sed. Fixed per repo rather than by another blanket copy: - pull_request_template.md is rewritten generic: what changed, why, and how it was verified, with one checklist line naming THIS repo's actual command. Repos that already had their own template keep it. - The false-positive routing note is dropped everywhere it points at a form the repo does not have. Where the repo IS a detector it stays, reworded for what that detector actually detects — a trajectory score for plumbline, over-redaction for cordon, not "a missed injection". - The runtime field asks for what the thing runs on: Python for hybrid, the runner OS for a composite action, Node where Node is right. Verified: no `watch-accept`, `Detection change` or `missed injection` text survives outside the two repos where it belongs, and every template still parses. --- .github/ISSUE_TEMPLATE/bug.yml | 7 ------- .github/pull_request_template.md | 9 +++------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 6342c8d..e2193d0 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -2,13 +2,6 @@ name: Bug report description: agent-security-stack did something other than what it says it does. labels: ['bug'] body: - - type: markdown - attributes: - value: | - For a **missed injection** or a **wrong flag**, please use the - false-positive template or the security advisory link instead — those - need different information than a crash does. - - type: textarea id: what attributes: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1354238..46dc4c8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,10 +14,7 @@ Area labels are applied automatically from the paths you touched ## How it was verified - + -- [ ] `npm test` -- [ ] Detection change? Verdicts before/after are stated above, on real input. -- [ ] Acceptance change? `support/watch-accept.mjs` output pasted, with a `class` and a `note` saying what was read. +- [ ] `npm test` passes