diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..87bd271 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Global ownership — all paths default to the maintainer. +# Per Decision 014, external contributions are not accepted, so CODEOWNERS +# primarily serves as documentation and branch-protection review enforcement. + +* @devonartis diff --git a/.github/MAINTAINERS b/.github/MAINTAINERS new file mode 100644 index 0000000..89ff2f7 --- /dev/null +++ b/.github/MAINTAINERS @@ -0,0 +1,11 @@ +# MAINTAINERS — allowlist for contribution-policy.yml +# +# Users listed here bypass the auto-close policy in +# .github/workflows/contribution-policy.yml. Anyone else opening a PR +# (except dependabot and github-actions bot) gets auto-closed with a +# templated comment pointing to the issues-only contribution policy +# (Decision 014). +# +# Format: one GitHub username per line, no @ prefix, no inline comments. + +devonartis diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0f63e91 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,69 @@ +# Dependabot config for agentauth-core +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Why each ecosystem is here: +# - github-actions: SHA pinning (Task 22) means Dependabot is the only +# thing that rotates actions. Without this block, pinned SHAs stale. +# - gomod: direct and indirect Go module updates. Groups by dependency +# type so 'chore(deps): bump direct deps' PRs are reviewable as a unit. +# - docker: Dockerfile base image updates. Separate limit because these +# PRs often touch runtime behavior and deserve individual review. + +version: 2 +updates: + # GitHub Actions — SHA maintenance for pinned workflow steps + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 3 + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + - "github-actions" + + # Go modules — direct and indirect dependencies + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 3 + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + go-direct: + dependency-type: "direct" + go-indirect: + dependency-type: "indirect" + labels: + - "dependencies" + - "go" + + # Docker base images + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore(deps)" + include: "scope" + labels: + - "dependencies" + - "docker" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a334702 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,296 @@ +name: CI + +# GATE_LIST_START +# - build +# - vet +# - lint +# - format +# - contamination +# - unit-tests +# - gosec +# - govulncheck +# - go-mod-verify +# - unit-tests-race +# - docker-build +# - smoke-l25 +# - sbom +# GATE_LIST_END + +on: + pull_request: + branches: [develop] + push: + branches: [develop, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Parameterized — no hardcoded owner/repo names. Rebrand-resilient per Decision 015. +permissions: + contents: read + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - run: go build ./cmd/broker ./cmd/aactl + + vet: + name: vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - run: go vet ./... + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + # DO NOT use golangci/golangci-lint-action here. Its pre-built + # binaries are compiled against an older Go toolchain (≤ 1.23) + # and exit 3 on our code because the go.mod toolchain directive + # is 1.25.9 — the embedded linter can't parse 1.25 stdlib/SSA. + # + # Instead, `go install` golangci-lint from source. This compiles + # it with the CI runner's Go (matching our toolchain.go1.25.9) + # so it parses the same way as local developers' brew-installed + # binary. Migration to golangci-lint v2 (which fixes this) is + # tracked for a later cycle. + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 + - name: Run lint + run: golangci-lint run --config .golangci.yml ./... + + format: + name: format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - run: | + unformatted=$(gofmt -l .) + if [[ -n "$unformatted" ]]; then + echo "The following files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + contamination: + name: contamination + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - run: | + if grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/ 2>/dev/null; then + echo "FAIL: enterprise references found in core code" + exit 1 + fi + echo "PASS: no enterprise contamination" + + unit-tests: + name: unit-tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - run: go test -short -count=1 ./... + + unit-tests-race: + name: unit-tests-race + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - run: go test -race -count=1 -coverprofile=coverage.out ./... + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + files: ./coverage.out + fail_ci_if_error: false + verbose: true + + gosec: + name: gosec + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + # Exclusions (G117, G304, G101) are documented in .gosec.yml and + # mirrored in scripts/gates.sh and .golangci.yml. + - uses: securego/gosec@223e19b8856e00f02cc67804499a83f77e208f3c # v2.25.0 + with: + args: '-quiet -conf .gosec.yml -exclude=G117,G304,G101 -severity=medium ./...' + + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + go-mod-verify: + name: go-mod-verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - run: | + go mod verify + go mod tidy + if ! git diff --exit-code go.mod go.sum; then + echo "FAIL: go.mod or go.sum changed after 'go mod tidy'" + exit 1 + fi + + docker-build: + name: docker-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Build image + run: docker build -t agentauth-ci:${{ github.sha }} . + + smoke-l25: + name: smoke-l25 + runs-on: ubuntu-latest + needs: [docker-build] + env: + AA_ADMIN_SECRET: live-test-secret-32bytes-long-ok # known test fixture + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install python cryptography (for Ed25519 challenge-response) + run: python3 -m pip install --user cryptography + - name: Start broker + run: | + export AA_ADMIN_SECRET="$AA_ADMIN_SECRET" + ./scripts/stack_up.sh + for i in {1..30}; do + if curl -sf http://localhost:8080/v1/health >/dev/null 2>&1; then + echo "Broker up after $i seconds" + break + fi + sleep 1 + done + - name: Run L2.5 core contract smoke + run: ./scripts/smoke/core-contract.sh + - name: Teardown + if: always() + run: ./scripts/stack_down.sh || true + + sbom: + name: sbom + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: spdx-json + output-file: sbom.spdx.json + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: sbom + path: sbom.spdx.json + retention-days: 30 + + # dep-review is removed temporarily: actions/dependency-review-action + # requires GitHub Advanced Security (GHAS) on private repos. devonartis/ + # agentauth is private without GHAS, so the action fails with a 403 + # from the Dependency Graph API on every PR. Re-enable when: + # (a) the repo flips public (GHAS is free on public repos), OR + # (b) GHAS is purchased for the private repo. + # Tracking: TD-VUL-005 in TECH-DEBT.md. + + changelog: + name: changelog + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Check CHANGELOG.md diff + run: | + if echo '${{ toJson(github.event.pull_request.labels) }}' | grep -q '"skip-changelog"'; then + echo "Label 'skip-changelog' present — bypassing CHANGELOG check" + exit 0 + fi + BASE_SHA='${{ github.event.pull_request.base.sha }}' + if git diff --name-only "$BASE_SHA" HEAD | grep -q '^CHANGELOG.md$'; then + echo "PASS: CHANGELOG.md touched in this PR" + else + echo "FAIL: This PR does not touch CHANGELOG.md" + echo "Add a CHANGELOG entry or apply the 'skip-changelog' label if the PR is docs/tests-only." + exit 1 + fi + + gate-parity: + name: gate-parity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - run: ./scripts/test-gate-parity.sh + + gates-passed: + name: gates-passed + runs-on: ubuntu-latest + needs: + - build + - vet + - lint + - format + - contamination + - unit-tests + - unit-tests-race + - gosec + - govulncheck + - go-mod-verify + - docker-build + - smoke-l25 + - sbom + - gate-parity + if: always() + steps: + - name: Check all gates passed + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "One or more gates failed" + exit 1 + fi + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more gates were cancelled" + exit 1 + fi + echo "All gates passed" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..716e3f5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,48 @@ +name: CodeQL + +# DISABLED: CodeQL requires GitHub Code Scanning, which is a GitHub +# Advanced Security (GHAS) feature on private repos. devonartis/agentauth +# is currently private without GHAS, so github/codeql-action/analyze +# fails at the SARIF upload step. +# +# Re-enable when: +# (a) the repo flips public (Phase 4 of release strategy — GHAS is +# free on public repos), OR +# (b) GHAS is purchased for the private repo (~$49/committer/mo). +# +# Tracking: TD-VUL-006 in TECH-DEBT.md. +# +# The file is kept (not deleted) so the re-enable is a simple trigger +# swap — uncomment the `on:` block below, delete this comment header, +# and push. + +on: + # Disabled until public flip — see header comment. + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [go] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - uses: github/codeql-action/init@f94817b9f0deeb3871261446912ae8f854d1b675 # codeql-bundle-v2.25.1 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + - uses: github/codeql-action/autobuild@f94817b9f0deeb3871261446912ae8f854d1b675 # codeql-bundle-v2.25.1 + - uses: github/codeql-action/analyze@f94817b9f0deeb3871261446912ae8f854d1b675 # codeql-bundle-v2.25.1 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml new file mode 100644 index 0000000..c3971bc --- /dev/null +++ b/.github/workflows/contribution-policy.yml @@ -0,0 +1,113 @@ +name: Contribution Policy + +# SECURITY: This workflow uses pull_request_target, which runs in the base +# branch context with write permissions. This is required to close PRs. The +# workflow MUST NEVER check out the PR branch — checking out untrusted PR code +# with write tokens is a supply-chain compromise vector (the "pwn-request" +# attack class documented at https://securitylab.github.com/research/github-actions-preventing-pwn-requests/). +# +# This workflow only reads metadata (PR author, PR number) via the GitHub API. +# It does NOT run actions/checkout. + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + check-author: + name: Enforce contribution policy + runs-on: ubuntu-latest + steps: + - name: Check PR author against MAINTAINERS + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const author = pr.user.login; + const pr_number = pr.number; + + // Always-exempt bots + const bot_exempt = ['dependabot[bot]', 'github-actions[bot]', 'renovate[bot]']; + if (bot_exempt.includes(author)) { + core.info(`Bot author ${author} exempt — no action`); + return; + } + + // Check if author has write access to the repo + try { + const { data: perms } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: author, + }); + if (['admin', 'maintain', 'write'].includes(perms.permission)) { + core.info(`Author ${author} has ${perms.permission} access — exempt`); + return; + } + } catch (e) { + // Not a collaborator — continue to MAINTAINERS check + } + + // Read MAINTAINERS via GitHub API (NOT via checkout) — reading from + // the base ref so a PR can't alter its own allowlist. + let maintainers = []; + try { + const { data: file } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/MAINTAINERS', + ref: context.payload.pull_request.base.ref, + }); + const content = Buffer.from(file.content, 'base64').toString('utf-8'); + maintainers = content + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + } catch (e) { + core.warning(`Could not read MAINTAINERS file: ${e.message}`); + } + + if (maintainers.includes(author)) { + core.info(`Author ${author} in MAINTAINERS — exempt`); + return; + } + + // Not exempt — enforce policy + core.info(`Author ${author} not exempt — closing PR per Decision 014`); + + const policy_comment = [ + `Hi @${author}, thank you for your interest in AgentAuth!`, + '', + 'Per our contribution policy ([Decision 014](https://github.com/' + owner + '/' + repo + '/blob/develop/CONTRIBUTING.md)), AgentAuth does not accept external code contributions at this time — including bug fixes.', + '', + 'We actively welcome:', + '- **Bug reports** — please [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new)', + '- **Feature requests** — same place', + '- **Security vulnerabilities** — please see [SECURITY.md](https://github.com/' + owner + '/' + repo + '/blob/develop/SECURITY.md) for the responsible disclosure process', + '', + 'This policy exists because we\'re still defining our contribution workflow (test plan, merge process, review gates). Opening to PRs before that\'s ready would mean every PR becomes a coaching session, which wouldn\'t be fair to you or to us. The policy will be revisited once the workflow is documented and tested.', + '', + 'This PR will be closed automatically. Please don\'t take it personally — the bot is enforcing policy, not judging your work. We genuinely appreciate the interest.', + '', + '_Auto-enforced by `.github/workflows/contribution-policy.yml`_', + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: policy_comment, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr_number, + state: 'closed', + }); diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..76a740c --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,78 @@ +name: Nightly full regression + +on: + schedule: + - cron: '17 5 * * *' # daily 05:17 UTC + workflow_dispatch: # manual trigger for ad-hoc runs + +permissions: + contents: read + issues: write # to open triage issues on failure + +jobs: + regression: + name: L4 full regression + runs-on: ubuntu-latest + env: + AA_ADMIN_SECRET: live-test-secret-32bytes-long-ok + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: develop + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Build broker image + run: | + export AA_ADMIN_SECRET="$AA_ADMIN_SECRET" + docker compose build + + - name: Run full regression suite + id: regression + run: ./scripts/gates.sh regression + continue-on-error: true + + - name: Upload evidence on failure + if: steps.regression.outcome == 'failure' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: regression-evidence-${{ github.run_id }} + path: tests/**/evidence/ + retention-days: 14 + + - name: Open issue on failure + if: steps.regression.outcome == 'failure' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const run_url = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + const short_sha = context.sha.substring(0, 7); + await github.rest.issues.create({ + owner, + repo, + title: `Nightly regression failed — ${short_sha}`, + body: [ + '# Nightly L4 regression failure', + '', + `**Commit:** \`${short_sha}\``, + `**Branch:** develop`, + `**Workflow run:** ${run_url}`, + '', + 'The nightly full regression suite failed. Evidence uploaded as workflow artifact.', + '', + 'Triage steps:', + '1. Download the `regression-evidence-${{ github.run_id }}` artifact', + '2. Identify which batch failed (`scripts/gates.sh regression` output)', + '3. Reproduce locally: `./scripts/gates.sh regression`', + '4. Open a fix branch if the failure is real, or close this issue if flaky', + '', + '_Auto-created by `.github/workflows/nightly.yml`_', + ].join('\n'), + labels: ['regression', 'nightly', 'needs-triage'], + }); + + - name: Fail workflow if regression failed + if: steps.regression.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..9c8d262 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,48 @@ +name: Scorecard supply-chain security + +# DISABLED: OpenSSF Scorecard uploads SARIF to GitHub Code Scanning, +# which requires GitHub Advanced Security (GHAS) on private repos. +# devonartis/agentauth is currently private without GHAS. Also, +# several Scorecard checks (branch protection settings, code review +# policies) only signal meaningfully on public repos with a track +# record — running it on a private solo-maintainer repo would flag +# everything as low-score false negatives. +# +# Re-enable when the repo flips public (Phase 4). Scorecard is +# precisely the kind of outside-in signal that becomes valuable +# AT that moment — the first public run is the baseline score that +# gets published on the badge (Task 30). +# +# Tracking: TD-VUL-006 in TECH-DEBT.md (same entry as CodeQL). + +on: + workflow_dispatch: # manual trigger only until public flip + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + - uses: github/codeql-action/upload-sarif@f94817b9f0deeb3871261446912ae8f854d1b675 # codeql-bundle-v2.25.1 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 4bdd4d8..00f4959 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ __pycache__/ # Per-user Claude Code permissions (personal, not shared) .claude/settings.local.json + +# M-sec gate artifacts — generated by ./scripts/gates.sh full, not checked in +coverage.out +sbom.spdx.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4f107e5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,62 @@ +# .golangci.yml — golangci-lint configuration for agentauth-core +# +# M-sec linter set: security-aware defaults plus the core Go linters. +# +# Tuning notes: +# - govet `fieldalignment` disabled: stylistic (struct memory layout), not a +# correctness check. Would force churn across every public DTO. +# - govet `shadow` disabled: triggers on the idiomatic `if err := x(); err != nil` +# pattern inside functions that already have an outer `err`. Not a bug class +# we've seen in practice. +# - errcheck excluded in `_test.go`: test code intentionally ignores returns +# from setup helpers (io.ReadAll in fixtures, json.Marshal on fixed inputs). +# Production code still runs the full check. +# - gosec in tests: excluded (weak random, subprocess, etc. are legit in tests). + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + disable-all: true + enable: + - errcheck # unchecked errors + - gosec # security — also gated separately + - govet # go vet + - ineffassign # unused assignments + - staticcheck # static analysis + - unused # unused code + - gosimple # code simplification + - bodyclose # HTTP body close (critical for broker HTTP client) + - misspell # typos in comments/strings + - gofmt # formatting (also gated separately) + - goimports # import ordering + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + gosec: + config-file: .gosec.yml + excludes: + - G117 # see .gosec.yml — broker API returns tokens by design + - G304 # see .gosec.yml — file paths from operator config, not user input + - G101 # see .gosec.yml — domain identifiers trip credential-name heuristic + govet: + enable-all: true + disable: + - fieldalignment # stylistic, not correctness + - shadow # noisy on idiomatic err-scoped blocks + misspell: + locale: US + +issues: + exclude-dirs: + - vendor + exclude-rules: + # Test files: allow weak random/panic in fixtures and unchecked setup helpers. + - path: _test\.go + linters: + - gosec + - errcheck diff --git a/.gosec.yml b/.gosec.yml new file mode 100644 index 0000000..d028ca2 --- /dev/null +++ b/.gosec.yml @@ -0,0 +1,20 @@ +{ + "_comment_format": "gosec expects JSON. The .yml extension is historical (matches other linter configs). DO NOT rewrite as YAML — gosec will refuse to load it.", + "_comment_policy": "No silent suppressions. Every globally-excluded rule below has a documented reason. Reviewers audit this file.", + "_comment_severity": "Block on MEDIUM and HIGH findings. LOW is advisory (typically unhandled errcheck findings that belong to the linter pipeline, not the security pipeline).", + + "_comment_excluded_rules": { + "G117": "Marshaled struct field matching secret pattern. Excluded globally. A credential broker's API responses are required to return tokens and client secrets — every broker response DTO (AccessToken, ClientSecret, launch_token) trips this rule. The product's entire job is to return credentials. Product-incompatible.", + "G304": "File inclusion via variable. Excluded globally. Every broker file read comes from startup config (signing key path, config file path, TLS cert/key paths) — all operator-supplied, none from untrusted user input. Contamination grep + code review covers the path-injection class instead.", + "G101": "Hardcoded credentials (LOW confidence). Excluded globally. Gosec pattern-matches identifiers containing 'auth', 'token', 'secret' against known-credential heuristics. For a credential broker every domain identifier trips this — audit event names (token_auth_failed), DTO field names (access_token), error messages. False-positive rate is ~100%." + }, + + "global": { + "nosec": "false", + "audit": "false", + "exclude-dir": ["vendor", "tests"] + }, + + "severity": "medium", + "confidence": "medium" +} diff --git a/.plans/designs/2026-04-10-ci-build-gates-msec-design.md b/.plans/designs/2026-04-10-ci-build-gates-msec-design.md new file mode 100644 index 0000000..c82feaf --- /dev/null +++ b/.plans/designs/2026-04-10-ci-build-gates-msec-design.md @@ -0,0 +1,511 @@ +# Design: CI / Build / Gates — M-sec v1 + +**Created:** 2026-04-10 +**Status:** DRAFT — pending user review +**Scope:** `agentauth-core` reference implementation of the M-sec CI pipeline. +**Strategic decision:** [Decision 015 — CI/Gates Strategy, Security-First, Rebrand-Resilient](../../../../Library/Mobile%20Documents/iCloud~md~obsidian/Documents/KnowledgeBase/10-Projects/AgentAuth/decisions/015-ci-gates-security-first.md) (Obsidian KB). This document is the "how"; Decision 015 is the "why." +**Related:** Decision 013 (AgentWrit rebrand), Decision 014 (no external contributions), ADR 009 (acceptance tests before merge). + +--- + +## 1. What this document covers + +Decision 015 settled the strategic layer: sequencing (CI before rebrand), scope (M-sec, not generic M), architecture (Option B — parallel per-gate jobs with a local-mirror script and a parity test), smoke strategy (L2.5 core contract on PR + L4 nightly), and companion infrastructure choices (Dependabot, contribution policy, CHANGELOG gate, pre-commit deferred). + +This design covers the **implementation-level architecture**: concrete file structure, workflow responsibilities, gate-by-gate definitions, how the local `gates.sh` and CI jobs stay in sync, the L2.5 smoke script's contract, rollout sequence, and a small set of questions for the plan phase. + +It stops short of committing to exact YAML, action versions, or pinned SHAs — those belong in the implementation plan (devflow Step 3). + +--- + +## 2. Current state baseline + +| Thing | State | Notes | +|---|---|---| +| `.github/` directory | **Does not exist** | Greenfield for workflows. | +| `scripts/gates.sh` | Exists | Three modes (`task`/`module`/`regression`), `run_gate`/`warn_gate`/`skip_gate` abstractions, sequential execution, gosec currently non-blocking (`warn_gate`), no govulncheck, no contamination grep, no SBOM. Dead references to `live_test.sh` and `live_test_docker.sh` (deleted in CC v4 per MEMORY.md) gracefully skipped via `[ -x ]` guard. | +| `CHANGELOG.md` | Exists | Hand-edited per devflow standing rule. | +| `.githooks/` | Does not exist | Pre-commit hooks deferred (separate cycle). | +| Go version | `go 1.24.0` / toolchain `go1.25.7` (from `go.mod`) | All workflows read via `go-version-file: go.mod`. | +| Module path | `github.com/devonartis/agentauth` (in `go.mod`) | This is the only legitimate place for a hardcoded name. Every other reference in workflows must parameterize via `${{ github.repository }}` and `${{ github.repository_owner }}`. | +| Acceptance tests | `tests//` directories with per-story evidence files and (sometimes) `regression.sh` runners per batch. | L4 nightly wraps these. | +| Docker lifecycle | `scripts/stack_up.sh` + `scripts/stack_down.sh`, test admin secret `live-test-secret-32bytes-long-ok` per MEMORY.md | L2.5 smoke reuses both. | +| Existing branch protection | None configured yet (private repo, single maintainer). | Added as part of rollout. | + +--- + +## 3. Target file structure + +``` +.github/ +├── workflows/ +│ ├── ci.yml # PR/push gates — parallel jobs incl. L2.5 smoke +│ ├── codeql.yml # CodeQL SAST (GitHub template, adapted) +│ ├── scorecard.yml # OpenSSF Scorecard (GitHub template, adapted) +│ ├── nightly.yml # L4 full regression (schedule) +│ └── contribution-policy.yml # Decision 014 enforcement +├── dependabot.yml # SHA maintenance for github-actions, gomod, docker +├── CODEOWNERS # New — owner of all paths (single maintainer today) +└── MAINTAINERS # New — allowlist consumed by contribution-policy.yml + +scripts/ +├── gates.sh # EXTENDED — add contamination, govulncheck, go mod verify, docker build, smoke, SBOM gates; flip gosec to blocking; clean up dead live_test.sh refs +├── smoke/ +│ └── core-contract.sh # NEW — L2.5 smoke script (used by gates.sh + ci.yml) +└── test-gate-parity.sh # NEW — asserts ci.yml gate list == gates.sh gate list + +.gosec.yml # NEW — explicit gosec config (no silent ignores) +.golangci.yml # NEW — security-aware golangci-lint config +``` + +All new files live in paths that `strip_for_main.sh` does NOT strip — this infrastructure ships to `main` and is part of the public repo. + +--- + +## 4. Workflow files — purpose and shape + +### 4.1 `ci.yml` — the main gate pipeline + +**Purpose:** Run all PR-blocking gates in parallel on every PR and every push to `develop`/`main`. + +**Triggers:** +```yaml +on: + pull_request: + branches: [develop] + push: + branches: [develop, main] +``` + +**Job shape:** one job per gate (parallel), plus a final `gates-passed` aggregator job that `needs:` all others. Branch protection requires `gates-passed` as the single required check, so adding/removing individual gates doesn't churn the branch-protection config. + +**Jobs (all parallel unless noted):** + +| Job ID | Purpose | Key step (abbreviated) | Blocking? | +|---|---|---|---| +| `build` | Both binaries compile | `go build ./cmd/broker ./cmd/aactl` | Yes | +| `unit-tests` | Race-enabled unit tests | `go test -race -count=1 -coverprofile=coverage.out ./...` | Yes | +| `lint` | golangci-lint with `.golangci.yml` | `golangci-lint run ./...` | Yes | +| `format` | `gofmt -l` returns empty | `test -z "$(gofmt -l .)"` | Yes | +| `vet` | `go vet` | `go vet ./...` | Yes | +| `contamination` | Zero enterprise refs in core | `! grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/` | Yes | +| `gosec` | Security static analysis | `gosec -conf .gosec.yml ./...` (blocking — changed from current `warn_gate`) | Yes | +| `govulncheck` | Known-CVE check on dependencies | `govulncheck ./...` | Yes | +| `go-mod-verify` | Module integrity + tidy check | `go mod verify && go mod tidy -diff` | Yes | +| `docker-build` | Multi-stage image builds | `docker build -t agentauth-ci:${{ github.sha }} .` | Yes | +| `smoke-l2.5` | Core contract smoke (see §5) | `./scripts/smoke/core-contract.sh` against the CI-built image | Yes | +| `dep-review` | New-dependency CVE gate (PRs only) | `actions/dependency-review-action` | Yes on PRs | +| `sbom` | SPDX SBOM generation | `anchore/sbom-action` → upload artifact | Yes (fails if generation errors) | +| `changelog` | CHANGELOG touched (see §8) | Diff check + `skip-changelog` label escape hatch | Yes on PRs | +| `gate-parity` | Local script matches workflow | `./scripts/test-gate-parity.sh` | Yes | +| `gates-passed` | Aggregator — `needs:` all above | `echo "all gates passed"` | Yes (this is the required check) | + +**Concurrency:** `concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }` so superseded pushes cancel in-flight runs. + +**Coverage upload:** `codecov/codecov-action` step on the `unit-tests` job, using `coverage.out`. Informational only — not a gate, no threshold. Badge published separately. + +### 4.2 `codeql.yml` — GitHub-native SAST + +**Purpose:** Run CodeQL analysis on Go code, populate the Security tab, produce the CodeQL badge. + +**Triggers:** +```yaml +on: + pull_request: + branches: [develop] + push: + branches: [develop, main] + schedule: + - cron: '31 7 * * 1' # weekly Monday morning +``` + +**Based on GitHub's template** (`github/codeql-action/init@` → `analyze@`) with `language: go`. Minimal adaptation. + +**Blocking:** Yes (required for merge). + +### 4.3 `scorecard.yml` — OpenSSF Scorecard + +**Purpose:** Project-level security posture scoring (branch protection, SECURITY.md, Dependabot, pinned deps, dangerous workflows, etc.). Produces the Scorecard badge. + +**Triggers:** +```yaml +on: + push: + branches: [main] # default branch only, per OpenSSF guidance + schedule: + - cron: '25 3 * * 2' # weekly Tuesday + branch_protection_rule: # re-score when protection rules change +``` + +**Based on the OpenSSF template** (`ossf/scorecard-action@`). Minimal adaptation. + +**Blocking:** No — informational posture signal. Results upload to GitHub Security tab via `sarif` and appear in the badge once the repo flips public. + +### 4.4 `nightly.yml` — L4 full regression + +**Purpose:** Run the complete acceptance suite from `tests//` directories against a Docker-deployed broker every night. Catches regressions in stories not exercised by the L2.5 PR smoke. + +**Triggers:** +```yaml +on: + schedule: + - cron: '17 5 * * *' # daily 05:17 UTC + workflow_dispatch: # manual trigger for ad-hoc runs +``` + +**Steps:** +1. Checkout `develop` +2. `docker compose build` via `scripts/stack_up.sh` +3. `./scripts/gates.sh regression` (existing mode — iterates `tests/*/regression.sh`) +4. On failure: use `actions/github-script` to open an issue titled `Nightly regression failed — ` with the failing batch list +5. Always: upload evidence directories as workflow artifacts + +**Blocking:** No — informational. Does not block PRs. Opens issue on failure so regressions don't go unnoticed. + +### 4.5 `contribution-policy.yml` — Decision 014 enforcement + +**Purpose:** Mechanically enforce "no external contributions, bug reports only" by auto-closing PRs from non-maintainers with a templated comment pointing to the issues-only policy. + +**Trigger:** +```yaml +on: + pull_request_target: + types: [opened, reopened] +``` + +**Critical security note:** This workflow uses `pull_request_target` (runs in the base-branch context with write permissions, needed to close PRs). It **MUST NOT** check out the PR branch — that's the supply-chain compromise vector. The workflow reads only metadata (`github.event.pull_request.user.login`, `github.event.pull_request.number`) and takes policy actions via `gh` CLI. No `actions/checkout` step at all. + +**Logic:** +1. Read PR author login. +2. Exempt: `dependabot[bot]`, `github-actions[bot]`, anyone listed in `.github/MAINTAINERS`, anyone with write access to the repo. +3. If author is not exempt: + - Post templated comment: "Thanks for your interest in AgentAuth. Per Decision 014, we don't accept external code contributions at this time — including bug fixes. We actively welcome bug reports and feature requests via issues. For security vulnerabilities, see SECURITY.md. [Policy link]" + - Close the PR. +4. If author is exempt: exit 0 (workflow succeeds, no action taken). + +**Blocking:** Yes — a failure here means the policy wasn't enforced, which is a problem. + +**Permissions:** `pull-requests: write`, `issues: write`, `contents: read`. Nothing else. + +--- + +## 5. L2.5 smoke script — `scripts/smoke/core-contract.sh` + +**Contract this script proves:** The credential broker can issue a token, verify it, revoke it, and deny an out-of-scope request. + +**Inputs:** +- `BROKER_URL` (default `http://localhost:8080`) +- `ADMIN_SECRET` (default `live-test-secret-32bytes-long-ok` per MEMORY.md standing rule) + +**Assumption:** The broker is already running. The smoke script does NOT start/stop the broker — that's the caller's job (`gates.sh smoke` locally uses `stack_up.sh`/`stack_down.sh`; `ci.yml smoke-l2.5` job does the same). + +**Steps (each one must succeed or the script fails fast with a clear error):** + +| # | Step | Success criterion | +|---|---|---| +| 1 | Admin auth — `POST /v1/admin/login` with `$ADMIN_SECRET` | 200 OK with admin JWT in response | +| 2 | Register a test app — `POST /v1/admin/apps` with a canned payload | 200 OK with `app_id` in response | +| 3 | Create a launch token for the test app — `POST /v1/admin/launch-tokens` | 200 OK with launch token | +| 4 | Exchange launch token for a task-scoped agent token — `POST /v1/app/tokens` | 200 OK with JWT, scope == requested scope | +| 5 | Decode JWT header + payload, assert `alg=EdDSA`, `kid` present, `iss` matches broker default, `exp > iat`, `scope` matches request | All assertions pass | +| 6 | Verify the token is accepted — `POST /v1/agent/verify` with the token | 200 OK | +| 7 | Revoke the token by JTI — `POST /v1/admin/revocations` | 200 OK | +| 8 | Verify the revoked token is now rejected — `POST /v1/agent/verify` | 401 / 403 with `token_revoked` error | +| 9 | Attempt to issue a token OUTSIDE the app's scope ceiling — `POST /v1/app/tokens` with scope not in the app's allowed set | 403 with `scope_violation` error | + +**Determinism rules (non-negotiable):** +- No wall-clock assertions beyond "token issued at T is valid at T+1s." +- All test fixtures are fixed literals — no randomness, no time-based IDs beyond what the broker produces. +- No retries, no sleeps. If a step fails, the test fails. +- Output format: one line per step, `PASS` or `FAIL` with a reason. Final line `L2.5 SMOKE: PASS` or `L2.5 SMOKE: FAIL`. +- Exit code: 0 on full pass, 1 on any failure. + +**Dependencies:** `curl`, `jq`. No Go, no Python, no Docker from inside the script. Keeps it portable and fast. + +**Size target:** ≤ 200 lines of bash. If it grows larger, the contract is over-scoped — trim back. + +**What L2.5 explicitly does NOT verify:** +- Audit chain integrity +- Delegation chain construction +- Renewal TTL preservation +- Rate limiting +- TLS certificate validation +- Prometheus metrics content +- Any non-happy-path except the two negative cases (revoked, out-of-scope) + +Those live in the L4 nightly regression suite, not the L2.5 smoke. + +--- + +## 6. `gates.sh` extension plan + +The existing structure stays. New gates get added using the existing `run_gate` helper (blocking) or `skip_gate` helper (for optional tools). + +### 6.1 Changes to existing gates + +| Gate | Current | Target | Change | +|---|---|---|---| +| `build` | `go build ./...` | unchanged | none | +| `lint` | `golangci-lint` with fallback to `go vet` | `golangci-lint` with explicit `.golangci.yml` — **no fallback**, fail if tool missing | Remove fallback; tool must be present in CI and recommended locally | +| `unit tests` | `go test ./... -short -count=1` | `go test -race -count=1 -coverprofile=coverage.out ./...` (drop `-short` in full mode) | Add race + coverage | +| `security (gosec)` | `warn_gate` (non-blocking) | `run_gate` (blocking), config from `.gosec.yml` | **Flip to blocking** per M-sec rule | + +### 6.2 New gates (all blocking, all `run_gate`) + +| Gate | Command | Notes | +|---|---|---| +| `format` | `test -z "$(gofmt -l .)"` | Fails if any file needs gofmt | +| `vet` | `go vet ./...` | Already implicit in lint but explicit as its own gate for CI clarity | +| `contamination` | `! grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/` | Zero-tolerance per MEMORY.md standing rule | +| `govulncheck` | `govulncheck ./...` | Blocking — per Decision 015 rationale | +| `go-mod-verify` | `go mod verify && git diff --exit-code go.mod go.sum` after `go mod tidy` | Integrity + drift check | +| `docker-build` | `docker build -t agentauth-ci:local .` | Build-only, no publish | +| `smoke-l2.5` | `./scripts/smoke/core-contract.sh` (broker must be up — caller's responsibility) | Fast core-contract verification | +| `sbom` | `syft packages dir:. -o spdx-json=sbom.spdx.json` | Non-destructive — fails only on generation error | + +### 6.3 Mode rework + +Current modes (`task` / `module` / `regression`) map to M-sec reality as: + +| Mode | What runs | Use case | +|---|---|---| +| `task` | build, vet, lint, format, contamination, unit tests (`-short`), gosec, govulncheck, go-mod-verify | Fast dev-loop gate — ~1-2 minutes locally | +| `full` (renamed from `module`) | All `task` gates + race-enabled unit tests (no `-short`) + docker-build + smoke-l2.5 (requires `stack_up.sh` first) + sbom | Full local verification — mirrors `ci.yml` | +| `regression` | Iterate `tests/*/regression.sh` (existing behavior) | Runs the L4 equivalent locally | + +`module` is retained as a deprecated alias for `full` to avoid breaking muscle memory. + +### 6.4 Dead reference cleanup + +Remove the `live_test.sh` and `live_test_docker.sh` branches from the current `module` block — those files were deleted in CC v4 but the `[ -x ]` guard keeps the references silently alive. Replace with the new `smoke-l2.5` gate. + +--- + +## 7. Parity test — `scripts/test-gate-parity.sh` + +**Purpose:** Fail CI if `gates.sh`'s gate list and `ci.yml`'s job list disagree about which gates exist. + +**Mechanism:** +1. `gates.sh --list-gates` (new flag) outputs a sorted list of gate IDs, one per line. Implementation: the script reads its own gate definitions from a single top-of-file array and prints them. +2. `ci.yml` exposes its gate list via a job matrix or a documented comment block that the parity test parses. Simpler option: `ci.yml` has a `gate-list` step in the `gates-passed` aggregator that echoes the same list. +3. The parity test diffs the two outputs and fails on any difference. + +**Runs as:** a blocking job in `ci.yml` (self-hosting — the parity gate is itself a gate). + +**Failure mode:** drift is usually caused by adding a gate to one side and forgetting the other. The test prints which gate is missing from which side so the fix is obvious. + +**Implementation size target:** ≤ 50 lines of bash. + +--- + +## 8. CHANGELOG gate mechanism + +**Rule (from FLOW.md standing rule):** Every user-facing change must update `CHANGELOG.md` in the same commit/PR. This gate makes it mechanical. + +**Implementation (PR-only gate):** +1. Compute the PR diff: `git diff --name-only ${{ github.event.pull_request.base.sha }} HEAD` +2. If `CHANGELOG.md` is in the list → PASS +3. If the PR has the `skip-changelog` label → PASS (bypass is intentional, label is audit-visible) +4. Otherwise → FAIL with a comment explaining the gate and the label + +**First-failure comment:** On first failure for a given PR, post a templated comment: +> This PR doesn't touch `CHANGELOG.md`. Per project policy, user-facing changes need a CHANGELOG entry. If this PR is docs-only, test-only, or otherwise not user-facing, apply the `skip-changelog` label and re-run CI. + +**Label creation:** `skip-changelog` label gets created as part of the rollout (`gh label create skip-changelog`). + +**Not gated on push to develop/main** — PRs are where the discipline matters; direct pushes to develop are rare and skip-label doesn't apply there anyway. + +--- + +## 9. Dependabot config — `.github/dependabot.yml` + +**Ecosystems watched:** + +| Ecosystem | Directory | Schedule | Grouping | +|---|---|---|---| +| `github-actions` | `/` | Weekly (Monday) | Grouped: all actions in one PR | +| `gomod` | `/` | Weekly (Monday) | Grouped: direct dependencies in one PR, indirect in another | +| `docker` | `/` | Weekly (Monday) | Dockerfile base images | + +**Version strategy:** `auto` (Dependabot picks increase semantics based on semver). + +**Open PR limit:** 3 per ecosystem (avoids PR flood). + +**PR commit-message prefix:** `chore(deps):` — matches existing CHANGELOG convention. + +**Allowlist:** Dependabot PRs are exempted from the contribution-policy gate (see §4.5). + +**Rebase strategy:** `auto` — Dependabot keeps PRs current as develop advances. + +--- + +## 10. Pinned SHA strategy + +**Rule:** Every `uses:` in every workflow file pins to a 40-character SHA, not a tag. Tags are mutable; SHAs are not. + +**Format:** `uses: actions/checkout@<40-char-sha> # v4.1.1` — the comment documents the human-readable version for review purposes. + +**Discovery process for the initial pin:** +1. For each action we use, visit the action's releases page, find the latest stable release. +2. Record the full SHA of that release's tag commit. +3. Write the workflow with the SHA + comment. + +**Maintenance:** Dependabot's `github-actions` ecosystem watches SHAs and opens grouped update PRs weekly. Dependabot can update both the SHA and the comment in a single PR. + +**Actions we'll pin in v1 (non-exhaustive — final list in the plan):** +- `actions/checkout` +- `actions/setup-go` +- `actions/upload-artifact` +- `actions/github-script` +- `github/codeql-action/init` +- `github/codeql-action/analyze` +- `golangci/golangci-lint-action` +- `securego/gosec` +- `ossf/scorecard-action` +- `actions/dependency-review-action` +- `anchore/sbom-action` +- `codecov/codecov-action` + +**Escape hatch:** `# dependabot: pin-exact` comment if any action must never auto-update (none expected in v1). + +--- + +## 11. Secret management + +**Only one secret is needed for v1 CI:** the test admin secret for the L2.5 smoke job. + +**Problem:** The test admin secret is a literal constant (`live-test-secret-32bytes-long-ok`) and is already in MEMORY.md as the canonical value. It's not actually secret — it's a well-known test fixture. Putting it in GitHub Secrets creates the *impression* of secrecy without any actual property. + +**Decision:** Pass it as a workflow env var, not a repo secret. Document clearly in the workflow that this is a known test fixture, not a production secret, and cross-reference MEMORY.md. + +```yaml +env: + AA_ADMIN_SECRET: live-test-secret-32bytes-long-ok # known test fixture, see MEMORY.md +``` + +**Future real secrets (not in v1):** +- `CODECOV_TOKEN` — if Codecov upload fails without token on private repo, add as repo secret. +- `GHCR_TOKEN` — not needed in v1 (no publish). +- Nothing else expected until release automation (L scope, later cycle). + +**Zero-secret posture until forced otherwise.** The less secret surface the CI has, the less to defend. + +--- + +## 12. Branch protection checklist + +Applied to both `develop` and `main` after initial rollout succeeds and `gates-passed` + `codeql` have run green at least once. + +**Required status checks:** +- `gates-passed` (aggregator job from `ci.yml`) +- `codeql` (from `codeql.yml`) + +**Do not list:** individual gate job names. Branch protection keyed on `gates-passed` means adding/removing gates doesn't require touching protection config. + +**Other rules:** +- Require PRs to merge (no direct push to `develop` except for maintainers in emergencies) +- Dismiss stale approvals on new commits +- Require branches to be up to date before merging +- Do NOT require signed commits (out of scope for v1) +- Do NOT require linear history (GitFlow uses merge commits) + +**Who sets this up:** Manual `gh api` calls during rollout, documented in the plan. Scripted idempotent setup is out of scope for v1 — one-time human operation with documented steps is simpler. + +--- + +## 13. Rollout plan + +Building CI that runs on `develop` while also protecting `develop` creates a chicken-and-egg problem. The rollout sequences around it: + +| Phase | Action | Gate for proceeding | +|---|---|---| +| **R1** | Create `feature/ci-msec` branch off `develop`. All work happens here. | — | +| **R2** | Write `scripts/gates.sh` extensions + `scripts/smoke/core-contract.sh` + `scripts/test-gate-parity.sh` + configs (`.gosec.yml`, `.golangci.yml`, `.github/dependabot.yml`). Run `./scripts/gates.sh full` locally. | All new local gates green against a `stack_up.sh` broker | +| **R3** | Write `ci.yml` + `codeql.yml` + `scorecard.yml` + `nightly.yml` + `contribution-policy.yml` with pinned SHAs and parameterized repo refs. | Files lint-check via `actionlint` (if available) | +| **R4** | Push `feature/ci-msec` to GitHub. Workflows will run on the push. Observe actual CI run. | All jobs green on `feature/ci-msec` | +| **R5** | Fix any CI-only issues discovered in R4 (platform differences, missing tools, unexpected permissions). Iterate until CI is green. | Stable green run | +| **R6** | Open PR from `feature/ci-msec` → `develop`. CI runs on the PR itself. | PR CI green | +| **R7** | Human review, merge to `develop`. | Merged | +| **R8** | After merge, configure branch protection on `develop` with `gates-passed` + `codeql` as required checks. | Protection active | +| **R9** | Merge `develop` → `main` (via fast-forward + `strip_for_main.sh`). CI runs on `main`. | `main` CI green | +| **R10** | Configure branch protection on `main`. | Protection active | +| **R11** | Wait 7 days, confirm Dependabot opens first weekly PR as expected, confirm nightly runs successfully. | Observation period clean | +| **R12** | Close the devflow cycle — CI v1 done. Update MEMORY.md + FLOW.md. | — | + +**Critical:** branch protection is applied AFTER the first green run, not before. Protecting `develop` before CI exists would block the PR that introduces CI. + +**If R4-R5 iteration produces many broken commits on `feature/ci-msec`**, that's fine — the branch gets squash-merged in R6 so the history stays clean. Alternatively, rebase/interactive-squash before opening the PR. + +--- + +## 14. Testing CI without breaking develop + +The design in §13 R4 pushes `feature/ci-msec` to the remote to exercise the workflows. A few safeguards: + +- **No branch protection on `feature/*` branches** — workflows run but nothing blocks force-pushes or rewrites while iterating. +- **Dependabot and contribution-policy workflows trigger on PRs to `develop`** — they won't activate just from pushing the feature branch. They first run when the PR is opened in R6. +- **Nightly workflow won't run** during rollout — its only trigger is `schedule`, so it waits for the next 05:17 UTC after merge. Optional: dispatch it manually via `workflow_dispatch` to smoke-test before R7. +- **Scorecard workflow won't run** during rollout — it triggers on push to `main` only. First run happens in R9. +- **CI workflow costs:** each CI run on the feature branch is ~5-10 minutes of runner time. Budget ~20-30 runs during iteration. + +--- + +## 15. Out of scope (explicit deferrals) + +None of the following are in M-sec v1. Listing them here so no one mistakes them for gaps: + +- **Release automation** — tag-triggered release workflow, multi-arch GHCR publish, release notes generation, SLSA provenance, cosign/sigstore signing, signed releases. Deferred to a later cycle (L scope per Decision 015, aligned with Decision 010 phase 4). +- **Pre-commit hooks** — extending `.githooks/pre-commit` with gofmt/vet/contamination. Deferred to a separate smaller cycle per Decision 015. +- **Coverage threshold gating** — coverage is published informationally, not gated. Avoid performative % chasing. +- **Matrix builds** — single Go version (from `go.mod`), single OS (`ubuntu-latest`). No cross-compile matrix. +- **CI caching beyond defaults** — `actions/setup-go` provides default Go module cache; no additional cache layers in v1. +- **Fuzz test gate** — `go test -fuzz` runs exist in some packages but aren't wired into CI. Candidate for a follow-up gate cycle. +- **Performance regression gate** — no benchmark budget enforcement in v1. +- **TLS cert rotation in CI** — the smoke test uses plain HTTP; TLS is a separate gate-in-progress concern. +- **Rebrand-related file rewrites** — this cycle builds CI; the rebrand cycle comes after. + +--- + +## 16. Open questions for the plan phase + +These aren't blockers — they're decisions that are cleaner to make with implementation code in front of us than in abstract: + +1. **golangci-lint exact linter list.** M-sec says "security-aware config." Candidates: `errcheck`, `gosec`, `govet`, `ineffassign`, `staticcheck`, `unused`, `gosimple`, `bodyclose`, `misspell`, `revive`, `gocritic`, `sqlclosecheck`. Full list finalized in the plan based on what the existing codebase tolerates without noise. Starting conservative (core set) and expanding is safer than starting maximal. + +2. **`.gosec.yml` suppressions.** Expect 3-10 gosec findings on first run (false positives on `crypto/rand` usage, `math/rand` in test fixtures, etc.). Each suppression needs a comment explaining the reason. Full suppression list determined in the plan. + +3. **`gates-passed` aggregator reporting.** Whether to use GitHub's native job-dependency aggregation or a custom aggregator job that posts a summary comment to the PR. Leaning toward the simpler native approach. + +4. **Dependabot PR author label.** Whether to add a label like `dependencies` automatically to Dependabot PRs for filtering. Default Dependabot behavior applies labels — confirming what those are and whether we need more. + +5. **MAINTAINERS file format.** Flat list of GitHub usernames vs. structured YAML. Flat list is simpler for a 1-2 person maintainer team; structured format matters only at larger scale. + +6. **Nightly regression issue template.** Fields in the auto-opened issue (failing batch, failing stories, link to workflow run, recent commits). Exact template finalized in the plan. + +7. **Codecov token on private repo.** Whether Codecov's free tier works without a token on private repos (it does for some languages, not for others). If a token is needed, add `CODECOV_TOKEN` repo secret during rollout. + +--- + +## 17. Success criteria + +This design is successfully implemented when: + +- [ ] All five workflow files exist in `.github/workflows/` with parameterized repo references (no hardcoded `devonartis/agentauth`) +- [ ] `scripts/gates.sh full` passes locally against a `stack_up.sh` broker +- [ ] `scripts/smoke/core-contract.sh` exists and verifies the 9-step contract deterministically +- [ ] `scripts/test-gate-parity.sh` exists and passes +- [ ] `.github/dependabot.yml`, `.github/MAINTAINERS`, `.github/CODEOWNERS` exist +- [ ] `.gosec.yml`, `.golangci.yml` exist with documented config +- [ ] Branch protection on `develop` requires `gates-passed` + `codeql` +- [ ] Branch protection on `main` requires `gates-passed` + `codeql` +- [ ] First Dependabot PR has been observed (within 7 days of merge) +- [ ] First nightly run has been observed (next morning after merge) +- [ ] First `pull_request_target` contribution-policy dry run succeeds (will be tested with a dummy PR from a non-maintainer account during rollout or the first real external PR) +- [ ] README has six M-sec badges (build, CodeQL, Scorecard, license, Go version, security policy) — Scorecard and CodeQL badges may read "pending" until first successful run +- [ ] Decision 015 is referenced in the merged PR description + +--- + +## 18. Next step — write the implementation plan + +After user review and approval of this design, invoke `superpowers:writing-plans` to create the implementation plan in `.plans/specs/` per devflow Step 2. The plan will break this design into ordered tasks with exact commands, file contents, and verification steps, ready for execution in Step 6. diff --git a/.plans/specs/2026-04-10-ci-build-gates-msec-plan.md b/.plans/specs/2026-04-10-ci-build-gates-msec-plan.md new file mode 100644 index 0000000..ccc3899 --- /dev/null +++ b/.plans/specs/2026-04-10-ci-build-gates-msec-plan.md @@ -0,0 +1,2551 @@ +# M-sec CI / Build / Gates v1 — Implementation Plan + +> **For agentic workers:** This plan implements the M-sec CI pipeline on `feature/ci-msec`. Work task-by-task, committing after each task unless a task says otherwise. Many tasks are "create file" + "run verification" + "commit" — lean but explicit. + +**Goal:** Build a security-product-grade CI/build/gates pipeline for `agentauth-core` that runs on every PR and push to `develop`/`main`, with a local `gates.sh` mirror kept in sync via a parity test, ready to act as the safety net for the future AgentWrit rebrand PR. + +**Architecture:** Parallel per-gate GitHub Actions jobs (Option B from Decision 015) in five workflow files. Local `scripts/gates.sh` mirrors the same gate set with a parity-test job enforcing drift-free alignment. L2.5 core contract smoke (issue/verify/revoke/deny) on every PR; L4 full regression nightly (informational). Pinned-SHA supply chain discipline with Dependabot maintenance. + +**Tech Stack:** GitHub Actions, Go 1.24+ (from `go.mod`), `golangci-lint`, `gosec`, `govulncheck`, `syft` (SBOM), CodeQL, OpenSSF Scorecard, Dependabot, Docker, bash, `jq`, `curl`. + +**Source of truth for the WHY:** Obsidian KB Decision 015 — "CI/Gates Strategy — Security-First, Rebrand-Resilient" (`10-Projects/AgentAuth/decisions/015-ci-gates-security-first.md`). Don't re-debate strategy in this plan — if a choice seems odd, check Decision 015 first. + +**Source of truth for the HOW at architecture level:** `.plans/designs/2026-04-10-ci-build-gates-msec-design.md`. This plan is the task-level expansion of that design. + +**Important notes before starting:** +1. **Pinned SHAs use placeholder tags in this plan.** The plan writes workflows with tag references like `actions/checkout@v4`. Task 22 pins every action to its 40-char SHA with a `# v4.x.y` comment before first push. Don't skip Task 22 — unpinned actions are a supply chain vulnerability for a security product. +2. **Don't push the feature branch until Task 22 is complete.** Pinning SHAs before the first push keeps the commit history clean and means Dependabot's first rotation works against the intended baseline. +3. **The L4 nightly and Scorecard workflows won't trigger during rollout** — they're `schedule`-based. That's fine; they'll fire after merge. +4. **Branch protection is applied AFTER the first green CI run**, not before. Protecting develop before CI exists would block the PR that introduces CI. + +--- + +## File Structure + +Files this plan creates or modifies: + +``` +Created: + .gosec.yml (gosec config) + .golangci.yml (golangci-lint config) + .github/dependabot.yml (Dependabot config) + .github/CODEOWNERS (ownership) + .github/MAINTAINERS (contribution-policy allowlist) + .github/workflows/ci.yml (main gate pipeline) + .github/workflows/codeql.yml (CodeQL SAST) + .github/workflows/scorecard.yml (OpenSSF Scorecard) + .github/workflows/nightly.yml (L4 full regression) + .github/workflows/contribution-policy.yml (Decision 014 enforcement) + scripts/smoke/core-contract.sh (L2.5 smoke script) + scripts/test-gate-parity.sh (parity enforcement) + +Modified: + scripts/gates.sh (extend with new gates, flip gosec to blocking, clean dead refs) + CHANGELOG.md (add Unreleased entry — each task's commit) + README.md (add six badge lines — final task) +``` + +--- + +## Phase A — Local infrastructure (Tasks 1–11) + +Phase A runs locally on `feature/ci-msec` without any GitHub Actions interaction. Goal: `./scripts/gates.sh full` passes against a `stack_up.sh` broker. + +--- + +### Task 1: Cut `feature/ci-msec` branch + +**Files:** n/a — branch operation + +- [ ] **Step 1: Verify clean working tree on develop** + +```bash +git status --short +git rev-parse --abbrev-ref HEAD +``` + +Expected: no output from `git status --short`, and branch is `develop`. + +- [ ] **Step 2: Pull latest develop** + +```bash +git fetch origin develop +git pull --ff-only origin develop +``` + +Expected: up-to-date. + +- [ ] **Step 3: Cut the feature branch** + +```bash +git checkout -b feature/ci-msec +git rev-parse --abbrev-ref HEAD +``` + +Expected: `feature/ci-msec`. + +- [ ] **Step 4: Verify the branch is based on the latest develop** + +```bash +git log --oneline -3 +``` + +Expected: top commit matches current `develop` HEAD. + +**No commit yet** — branch cut is a git state change, not a commit. + +--- + +### Task 2: Create `.gosec.yml` config + +**Files:** +- Create: `.gosec.yml` + +- [ ] **Step 1: Create the config file** + +```yaml +# .gosec.yml — gosec configuration for agentauth-core +# +# Policy: no silent suppressions. Every entry here has a comment +# explaining WHY it's suppressed. Reviewers audit this file. +# +# Severity: fail on HIGH and MEDIUM findings. LOW is advisory. + +global: + nosec: false # require explicit suppressions via config, not //nosec + audit: false + exclude-dir: + - vendor # third-party code is scanned separately + - tests # test fixtures may intentionally use weak crypto + +# Run all rules by default. Uncomment to disable specific rules with reason. +# rules: +# - G101 # Hardcoded credentials — enabled (catches accidental token leakage) +# - G104 # Unhandled errors — enabled (matches linter rule) +# - G404 # Weak random — enabled (crypto/rand required for token IDs) + +# Severity levels to report: high, medium, low +severity: medium +confidence: medium +``` + +- [ ] **Step 2: Install gosec locally if missing** + +```bash +if ! command -v gosec &>/dev/null; then + go install github.com/securego/gosec/v2/cmd/gosec@latest +fi +gosec -version +``` + +Expected: gosec version printed. + +- [ ] **Step 3: Run gosec with the new config against the repo** + +```bash +gosec -conf .gosec.yml ./... 2>&1 | tail -20 +``` + +Expected: findings report (likely some MEDIUM/HIGH findings — these are pre-existing and get addressed in Task 3.5 or suppressed in this file with justification). + +- [ ] **Step 4: Document any findings** + +Read through the gosec output. For each HIGH finding: +- If it's a real bug, file it as tech debt in `TECH-DEBT.md` and fix before merge +- If it's a false positive, add to `.gosec.yml` with a `# reason: ...` comment + +For each MEDIUM finding: +- Same triage + +**Do not ignore findings.** Document every decision. + +- [ ] **Step 5: Commit** + +```bash +git add .gosec.yml +git commit -m "chore(gates): add gosec config for M-sec pipeline" +``` + +--- + +### Task 3: Create `.golangci.yml` config + +**Files:** +- Create: `.golangci.yml` + +- [ ] **Step 1: Create the config file** + +```yaml +# .golangci.yml — golangci-lint configuration for agentauth-core +# +# M-sec linter set: security-aware defaults plus the core Go linters. +# Conservative starting set — expand after first clean run. + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + disable-all: true + enable: + - errcheck # unchecked errors + - gosec # security — also gated separately + - govet # go vet + - ineffassign # unused assignments + - staticcheck # static analysis + - unused # unused code + - gosimple # code simplification + - bodyclose # HTTP body close (critical for broker HTTP client) + - misspell # typos in comments/strings + - gofmt # formatting (also gated separately) + - goimports # import ordering + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + gosec: + config-file: .gosec.yml + govet: + enable-all: true + misspell: + locale: US + +issues: + exclude-dirs: + - vendor + exclude-rules: + # Test files allowed to use weak random and panic for fixtures + - path: _test\.go + linters: + - gosec +``` + +- [ ] **Step 2: Install golangci-lint if missing** + +```bash +if ! command -v golangci-lint &>/dev/null; then + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +fi +golangci-lint version +``` + +Expected: golangci-lint version printed. + +- [ ] **Step 3: Run lint with the new config** + +```bash +golangci-lint run ./... 2>&1 | tail -30 +``` + +Expected: some findings likely on first run. Triage: +- Fix real issues in place +- Add targeted `//nolint:linter_name // reason: ...` comments ONLY with justification +- Add broad exclusions to `.golangci.yml` ONLY if a whole linter is producing noise you plan to address later + +- [ ] **Step 4: Iterate until clean or documented** + +Re-run until `golangci-lint run ./...` exits 0. This may take several iterations on first pass. + +- [ ] **Step 5: Commit** + +```bash +git add .golangci.yml $(git diff --name-only) # include any in-place fixes +git commit -m "chore(gates): add golangci-lint M-sec config" +``` + +--- + +### Task 4: Install `govulncheck` locally and baseline + +**Files:** none (tool install + baseline check) + +- [ ] **Step 1: Install govulncheck** + +```bash +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck -version +``` + +Expected: govulncheck version printed. + +- [ ] **Step 2: Run govulncheck against the module** + +```bash +govulncheck ./... 2>&1 | tee /tmp/govulncheck-baseline.txt +``` + +Expected: either "No vulnerabilities found" OR a list of vulnerabilities. + +- [ ] **Step 3: Triage vulnerabilities** + +If vulnerabilities are reported: +- For each: update the dependency (`go get @latest && go mod tidy`) or document in `TECH-DEBT.md` with severity and rationale +- Re-run govulncheck until clean or every remaining vuln has a tech-debt entry + +**CI will block on govulncheck — this baseline must be clean before Phase B.** + +- [ ] **Step 4: Commit dependency updates (if any)** + +```bash +git add go.mod go.sum +git commit -m "chore(deps): update dependencies for govulncheck baseline" +``` + +Skip commit if no changes. + +--- + +### Task 5: Extend `scripts/gates.sh` — add new blocking gates + +**Files:** +- Modify: `scripts/gates.sh` + +The current `gates.sh` has modes `task`/`module`/`regression` and uses `run_gate`/`warn_gate`/`skip_gate`. We preserve that structure and add new gates. + +- [ ] **Step 1: Read current `scripts/gates.sh`** + +```bash +cat scripts/gates.sh +``` + +Understand the existing structure before modifying. + +- [ ] **Step 2: Rewrite `scripts/gates.sh` with M-sec extensions** + +Replace the entire file with this content: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# gates.sh — quality gate runner for AgentAuth (M-sec) +# +# Usage: +# ./scripts/gates.sh task Fast dev-loop gates (build/vet/lint/format/contamination/short tests/security) +# ./scripts/gates.sh full Full CI-mirror gates (task + race tests + docker-build + smoke-l2.5 + sbom) +# ./scripts/gates.sh regression L4 full regression — iterate tests/*/regression.sh +# ./scripts/gates.sh --list-gates Print gate IDs (one per line) for parity test +# +# 'module' is retained as a deprecated alias for 'full'. +# +# Local/CI parity: this script's gate IDs must match ci.yml's job matrix. +# scripts/test-gate-parity.sh enforces this. + +MODE="${1:-}" + +# Authoritative gate list — single source of truth. +# scripts/test-gate-parity.sh reads this array; ci.yml's gate-list step echoes +# the same strings. If you add/remove/rename a gate, update BOTH. +GATES_TASK=( + build + vet + lint + format + contamination + unit-tests + gosec + govulncheck + go-mod-verify +) +GATES_FULL=( + "${GATES_TASK[@]}" + unit-tests-race + docker-build + smoke-l2.5 + sbom +) + +if [[ "$MODE" == "--list-gates" ]]; then + for g in "${GATES_FULL[@]}"; do echo "$g"; done + exit 0 +fi + +if [[ -z "$MODE" ]]; then + echo "Usage: $0 {task|full|regression|--list-gates}" + exit 1 +fi + +# Alias: module -> full (deprecated) +if [[ "$MODE" == "module" ]]; then + echo "NOTE: 'module' is deprecated, use 'full'." >&2 + MODE="full" +fi + +if [[ "$MODE" != "task" && "$MODE" != "full" && "$MODE" != "regression" ]]; then + echo "Error: unknown mode '$MODE'. Use 'task', 'full', 'regression', or '--list-gates'." + exit 1 +fi + +PASS=0 +FAIL=0 +SKIP=0 + +run_gate() { + local name="$1" + shift + echo "" + echo "=== GATE: $name ===" + if "$@"; then + echo "--- PASS: $name ---" + PASS=$((PASS + 1)) + else + echo "--- FAIL: $name ---" + FAIL=$((FAIL + 1)) + fi +} + +skip_gate() { + local name="$1" + local reason="$2" + echo "" + echo "=== GATE: $name ===" + echo "--- SKIP: $reason ---" + SKIP=$((SKIP + 1)) +} + +# --- TASK gates --- + +run_gate "build" go build ./cmd/broker ./cmd/aactl + +run_gate "vet" go vet ./... + +# Lint: require golangci-lint (no fallback — M-sec policy) +if command -v golangci-lint &>/dev/null; then + run_gate "lint" golangci-lint run ./... +else + echo "ERROR: golangci-lint not installed. Install via: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + exit 1 +fi + +# Format: gofmt -l must return empty +run_gate "format" bash -c 'test -z "$(gofmt -l .)"' + +# Contamination: zero enterprise refs in core +run_gate "contamination" bash -c "! grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/ 2>/dev/null" + +run_gate "unit-tests" go test -short -count=1 ./... + +# Security: gosec (BLOCKING — flipped from warn per Decision 015) +if command -v gosec &>/dev/null; then + run_gate "gosec" gosec -quiet -conf .gosec.yml ./... +else + echo "ERROR: gosec not installed. Install via: go install github.com/securego/gosec/v2/cmd/gosec@latest" + exit 1 +fi + +# Vulnerability check: govulncheck (BLOCKING) +if command -v govulncheck &>/dev/null; then + run_gate "govulncheck" govulncheck ./... +else + echo "ERROR: govulncheck not installed. Install via: go install golang.org/x/vuln/cmd/govulncheck@latest" + exit 1 +fi + +# Module integrity + tidy drift +run_gate "go-mod-verify" bash -c 'go mod verify && go mod tidy && git diff --exit-code go.mod go.sum' + +# --- FULL gates (only if mode is full) --- + +if [[ "$MODE" == "full" ]]; then + run_gate "unit-tests-race" go test -race -count=1 -coverprofile=coverage.out ./... + + # Docker build: multi-stage image builds cleanly + if docker info >/dev/null 2>&1; then + run_gate "docker-build" docker build -t agentauth-ci:local . + else + skip_gate "docker-build" "Docker daemon not running" + fi + + # L2.5 smoke: core contract (issue/verify/revoke/deny) + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + if [[ -x "$SCRIPT_DIR/smoke/core-contract.sh" ]]; then + if docker info >/dev/null 2>&1; then + # Caller must ensure broker is running via stack_up.sh + if curl -sf http://localhost:8080/v1/health >/dev/null 2>&1; then + run_gate "smoke-l2.5" "$SCRIPT_DIR/smoke/core-contract.sh" + else + skip_gate "smoke-l2.5" "broker not reachable at localhost:8080 — run scripts/stack_up.sh first" + fi + else + skip_gate "smoke-l2.5" "Docker daemon not running" + fi + else + skip_gate "smoke-l2.5" "scripts/smoke/core-contract.sh not found or not executable" + fi + + # SBOM: syft SPDX output + if command -v syft &>/dev/null; then + run_gate "sbom" syft packages dir:. -o spdx-json=sbom.spdx.json --quiet + else + skip_gate "sbom" "syft not installed — install: brew install syft or https://github.com/anchore/syft" + fi +fi + +# --- REGRESSION gates (only if mode is regression) --- + +if [[ "$MODE" == "regression" ]]; then + echo "" + echo "=== REGRESSION: Running all previous phase tests ===" + reg_pass=0 + reg_fail=0 + for test_dir in tests/*/; do + phase=$(basename "$test_dir") + runner="" + if [ -f "$test_dir/regression.sh" ]; then + runner="$test_dir/regression.sh" + else + echo " SKIP $phase (no regression.sh runner)" + continue + fi + echo " RUN $phase ($runner)" + if bash "$runner"; then + echo " PASS $phase" + reg_pass=$((reg_pass + 1)) + else + echo " FAIL $phase" + reg_fail=$((reg_fail + 1)) + fi + done + echo "" + echo "=== REGRESSION SUMMARY: $reg_pass passed, $reg_fail failed ===" + if [[ $reg_fail -gt 0 ]]; then + echo "RESULT: FAILED" + exit 1 + else + echo "RESULT: PASSED" + exit 0 + fi +fi + +# --- Summary --- + +echo "" +echo "===============================" +echo " GATE SUMMARY ($MODE mode)" +echo "===============================" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +echo " SKIP: $SKIP" +echo "===============================" + +if [[ $FAIL -gt 0 ]]; then + echo "RESULT: FAILED" + exit 1 +else + echo "RESULT: PASSED" + exit 0 +fi +``` + +- [ ] **Step 3: Make sure it's still executable** + +```bash +chmod +x scripts/gates.sh +ls -l scripts/gates.sh +``` + +Expected: `-rwxr-xr-x` permissions visible. + +- [ ] **Step 4: Run `./scripts/gates.sh --list-gates`** + +```bash +./scripts/gates.sh --list-gates +``` + +Expected output (one per line): +``` +build +vet +lint +format +contamination +unit-tests +gosec +govulncheck +go-mod-verify +unit-tests-race +docker-build +smoke-l2.5 +sbom +``` + +- [ ] **Step 5: Run `./scripts/gates.sh task` and verify it passes** + +```bash +./scripts/gates.sh task +``` + +Expected: all task-mode gates pass. If any fail, fix them before moving on. The smoke and docker-build gates won't run in task mode, so this test focuses on the fast gates only. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/gates.sh +git commit -m "chore(gates): extend gates.sh with M-sec gate set + +- Add contamination grep, govulncheck, go-mod-verify as blocking gates +- Flip gosec from warn_gate to run_gate (blocking per Decision 015) +- Add docker-build, smoke-l2.5, sbom in 'full' mode +- Add --list-gates flag for parity test +- Rename 'module' to 'full' (retain as deprecated alias) +- Remove dead references to live_test.sh / live_test_docker.sh +- Require golangci-lint and gosec (no fallback — M-sec policy)" +``` + +--- + +### Task 6: Create `scripts/smoke/core-contract.sh` — L2.5 smoke script + +**Files:** +- Create: `scripts/smoke/core-contract.sh` + +This is the L2.5 core contract smoke — it verifies issue + verify + revoke + deny-out-of-scope in ≤ 200 lines of bash against a running broker. + +- [ ] **Step 1: Create the smoke directory** + +```bash +mkdir -p scripts/smoke +``` + +- [ ] **Step 2: Create `scripts/smoke/core-contract.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# core-contract.sh — L2.5 smoke test for agentauth-core +# +# Verifies the credential broker's core contract: +# 1. Admin can authenticate +# 2. Admin can register an app +# 3. App can receive a launch token +# 4. App can exchange launch token for an agent token +# 5. Agent token has correct JWT structure (EdDSA, kid, iss, scope) +# 6. Agent token is accepted by /v1/agent/verify +# 7. Admin can revoke the token +# 8. Revoked token is rejected +# 9. Out-of-scope token request is denied +# +# Caller's responsibility: start the broker before calling this script. +# This script does NOT start/stop the broker. +# +# Determinism: fixed fixtures, no random values, no sleeps, no retries. + +BROKER_URL="${BROKER_URL:-http://localhost:8080}" +ADMIN_SECRET="${AA_ADMIN_SECRET:-live-test-secret-32bytes-long-ok}" + +# Dependencies +for dep in curl jq; do + if ! command -v $dep &>/dev/null; then + echo "FAIL: missing dependency: $dep" + exit 1 + fi +done + +# Fixtures (fixed — no randomness) +APP_NAME="smoke-test-app" +APP_SCOPE_CEILING='["tasks:read","tasks:write"]' +REQUESTED_SCOPE='["tasks:read"]' +OUT_OF_SCOPE='["admin:all"]' + +step=0 +pass() { step=$((step+1)); echo " [$step] PASS: $1"; } +fail() { step=$((step+1)); echo " [$step] FAIL: $1 — $2"; echo "L2.5 SMOKE: FAIL"; exit 1; } + +echo "=== L2.5 Core Contract Smoke ===" +echo "Broker: $BROKER_URL" + +# --- Step 1: Admin auth --- +ADMIN_TOKEN=$(curl -sf -X POST "$BROKER_URL/v1/admin/login" \ + -H "Content-Type: application/json" \ + -d "{\"secret\":\"$ADMIN_SECRET\"}" \ + | jq -r '.token // empty') +if [[ -z "$ADMIN_TOKEN" ]]; then + fail "admin login" "no token in response" +fi +pass "admin login (got admin JWT)" + +# --- Step 2: Register app --- +APP_RESPONSE=$(curl -sf -X POST "$BROKER_URL/v1/admin/apps" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$APP_NAME\",\"scope_ceiling\":$APP_SCOPE_CEILING}") +APP_ID=$(echo "$APP_RESPONSE" | jq -r '.app_id // empty') +APP_SECRET=$(echo "$APP_RESPONSE" | jq -r '.app_secret // empty') +if [[ -z "$APP_ID" ]]; then + fail "app registration" "no app_id in response: $APP_RESPONSE" +fi +pass "app registered (app_id=$APP_ID)" + +# --- Step 3: Create launch token --- +LAUNCH_TOKEN=$(curl -sf -X POST "$BROKER_URL/v1/admin/launch-tokens" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"app_id\":\"$APP_ID\",\"scope\":$REQUESTED_SCOPE}" \ + | jq -r '.launch_token // empty') +if [[ -z "$LAUNCH_TOKEN" ]]; then + fail "launch token" "no launch_token in response" +fi +pass "launch token issued" + +# --- Step 4: Exchange for agent token --- +AGENT_TOKEN=$(curl -sf -X POST "$BROKER_URL/v1/app/tokens" \ + -H "Authorization: Bearer $LAUNCH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"scope\":$REQUESTED_SCOPE}" \ + | jq -r '.token // empty') +if [[ -z "$AGENT_TOKEN" ]]; then + fail "token exchange" "no token in response" +fi +pass "agent token exchanged" + +# --- Step 5: Decode and verify JWT structure --- +# JWT is base64url.base64url.base64url — decode header and payload +decode_jwt_part() { + local part=$1 + # base64url -> base64, pad, decode + local padded=$(echo -n "$part" | tr '_-' '/+') + while [ $((${#padded} % 4)) -ne 0 ]; do padded="${padded}="; done + echo "$padded" | base64 -d 2>/dev/null +} + +HEADER_B64=$(echo "$AGENT_TOKEN" | cut -d'.' -f1) +PAYLOAD_B64=$(echo "$AGENT_TOKEN" | cut -d'.' -f2) +HEADER=$(decode_jwt_part "$HEADER_B64") +PAYLOAD=$(decode_jwt_part "$PAYLOAD_B64") + +ALG=$(echo "$HEADER" | jq -r '.alg // empty') +KID=$(echo "$HEADER" | jq -r '.kid // empty') +ISS=$(echo "$PAYLOAD" | jq -r '.iss // empty') +EXP=$(echo "$PAYLOAD" | jq -r '.exp // empty') +IAT=$(echo "$PAYLOAD" | jq -r '.iat // empty') +SCOPE_CLAIM=$(echo "$PAYLOAD" | jq -c '.scope // empty') + +[[ "$ALG" == "EdDSA" ]] || fail "jwt alg" "expected EdDSA, got $ALG" +[[ -n "$KID" ]] || fail "jwt kid" "kid missing" +[[ -n "$ISS" ]] || fail "jwt iss" "iss missing" +[[ $EXP -gt $IAT ]] || fail "jwt exp" "exp ($EXP) not greater than iat ($IAT)" +[[ "$SCOPE_CLAIM" == "$REQUESTED_SCOPE" ]] || fail "jwt scope" "expected $REQUESTED_SCOPE, got $SCOPE_CLAIM" +pass "JWT structure valid (alg=EdDSA, kid present, scope matches)" + +# --- Step 6: Verify token is accepted --- +VERIFY_STATUS=$(curl -so /dev/null -w "%{http_code}" -X POST "$BROKER_URL/v1/agent/verify" \ + -H "Content-Type: application/json" \ + -d "{\"token\":\"$AGENT_TOKEN\"}") +[[ "$VERIFY_STATUS" == "200" ]] || fail "verify accepted" "expected 200, got $VERIFY_STATUS" +pass "token verified (200 OK)" + +# --- Step 7: Revoke the token --- +JTI=$(echo "$PAYLOAD" | jq -r '.jti // empty') +[[ -n "$JTI" ]] || fail "jwt jti" "jti missing for revocation" +REVOKE_STATUS=$(curl -so /dev/null -w "%{http_code}" -X POST "$BROKER_URL/v1/admin/revocations" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"jti\":\"$JTI\"}") +[[ "$REVOKE_STATUS" == "200" ]] || fail "revocation" "expected 200, got $REVOKE_STATUS" +pass "token revoked (jti=$JTI)" + +# --- Step 8: Verify revoked token is rejected --- +REVOKED_VERIFY_STATUS=$(curl -so /dev/null -w "%{http_code}" -X POST "$BROKER_URL/v1/agent/verify" \ + -H "Content-Type: application/json" \ + -d "{\"token\":\"$AGENT_TOKEN\"}") +if [[ "$REVOKED_VERIFY_STATUS" != "401" && "$REVOKED_VERIFY_STATUS" != "403" ]]; then + fail "revocation enforced" "expected 401/403, got $REVOKED_VERIFY_STATUS" +fi +pass "revoked token rejected ($REVOKED_VERIFY_STATUS)" + +# --- Step 9: Out-of-scope request denied --- +OOS_STATUS=$(curl -so /dev/null -w "%{http_code}" -X POST "$BROKER_URL/v1/app/tokens" \ + -H "Authorization: Bearer $LAUNCH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"scope\":$OUT_OF_SCOPE}") +[[ "$OOS_STATUS" == "403" ]] || fail "out-of-scope denied" "expected 403, got $OOS_STATUS" +pass "out-of-scope request denied (403)" + +echo "" +echo "L2.5 SMOKE: PASS" +exit 0 +``` + +- [ ] **Step 3: Make it executable** + +```bash +chmod +x scripts/smoke/core-contract.sh +``` + +- [ ] **Step 4: Test against a running broker** + +```bash +# Start the broker if not already running +export AA_ADMIN_SECRET="live-test-secret-32bytes-long-ok" +./scripts/stack_up.sh + +# Wait for health +sleep 2 +curl -sf http://localhost:8080/v1/health + +# Run the smoke +./scripts/smoke/core-contract.sh +``` + +Expected output ends with `L2.5 SMOKE: PASS`. If any step fails, debug by: +- Check broker logs (`docker compose logs broker`) +- Check the exact curl command that failed (set `set -x` at top of script) +- Verify the endpoint paths against `docs/api.md` + +**IMPORTANT:** If any endpoint path is wrong (e.g., `/v1/admin/login` doesn't exist), check `docs/api.md` and update the smoke script to match. The smoke script MUST reflect reality, not the plan's assumptions. + +- [ ] **Step 5: Tear down the broker** + +```bash +./scripts/stack_down.sh +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/smoke/core-contract.sh +git commit -m "feat(gates): add L2.5 core contract smoke script + +Nine-step smoke verifying the credential broker's contract: +admin auth, app registration, launch token, agent token exchange, +JWT structure validation, verify accepted, revocation, revoke +enforcement, out-of-scope denial. + +Used by scripts/gates.sh full (locally) and ci.yml smoke-l2.5 job +(in CI). Caller is responsible for starting/stopping the broker. + +Deterministic: fixed fixtures, no sleeps, no retries." +``` + +--- + +### Task 7: Create `scripts/test-gate-parity.sh` — parity enforcement + +**Files:** +- Create: `scripts/test-gate-parity.sh` + +- [ ] **Step 1: Create the parity test** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# test-gate-parity.sh — enforce local/CI gate list alignment +# +# Reads gate IDs from: +# (a) scripts/gates.sh --list-gates +# (b) .github/workflows/ci.yml via grep of job IDs under `jobs:` +# +# Fails if the two lists differ. +# +# Used by: scripts/gates.sh full (indirectly) and ci.yml gate-parity job. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +GATES_SH="$SCRIPT_DIR/gates.sh" +CI_YML="$REPO_ROOT/.github/workflows/ci.yml" + +if [[ ! -x "$GATES_SH" ]]; then + echo "FAIL: $GATES_SH not found or not executable" + exit 1 +fi + +if [[ ! -f "$CI_YML" ]]; then + echo "FAIL: $CI_YML not found" + exit 1 +fi + +# Source of truth A: gates.sh +GATES_FROM_SCRIPT=$("$GATES_SH" --list-gates | sort) + +# Source of truth B: ci.yml +# Extract the list of gate job IDs. The ci.yml has a comment block listing +# the canonical gate IDs in the format: +# # GATE_LIST_START +# # - build +# # - vet +# # ... +# # GATE_LIST_END +# We parse this block so the list is a single source of truth per file. +GATES_FROM_CI=$(awk ' + /# GATE_LIST_START/ { in_block=1; next } + /# GATE_LIST_END/ { in_block=0; next } + in_block && /^# - / { sub(/^# - /, ""); print } +' "$CI_YML" | sort) + +if [[ -z "$GATES_FROM_CI" ]]; then + echo "FAIL: no GATE_LIST_START/END block found in $CI_YML" + exit 1 +fi + +# Diff +if diff <(echo "$GATES_FROM_SCRIPT") <(echo "$GATES_FROM_CI") >/dev/null; then + echo "PASS: gate lists match ($(echo "$GATES_FROM_SCRIPT" | wc -l | tr -d ' ') gates)" + exit 0 +else + echo "FAIL: gates.sh and ci.yml disagree on the gate list" + echo "" + echo "--- gates.sh --list-gates ---" + echo "$GATES_FROM_SCRIPT" + echo "" + echo "--- ci.yml GATE_LIST block ---" + echo "$GATES_FROM_CI" + echo "" + echo "Diff:" + diff <(echo "$GATES_FROM_SCRIPT") <(echo "$GATES_FROM_CI") || true + exit 1 +fi +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x scripts/test-gate-parity.sh +``` + +- [ ] **Step 3: Do NOT run it yet** — ci.yml doesn't exist yet, so this will fail. That's expected. The script is ready; it'll be exercised starting Task 12. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/test-gate-parity.sh +git commit -m "chore(gates): add gate-parity enforcement script + +Reads gate IDs from gates.sh --list-gates and from ci.yml's +GATE_LIST_START/END comment block. Fails if they differ. + +Prevents local and CI gate definitions from silently drifting. +Runs as its own gate both locally (in 'full' mode once ci.yml +exists) and in CI (as the gate-parity job)." +``` + +--- + +### Task 8: Install `syft` locally and baseline SBOM + +**Files:** none (tool install + baseline SBOM generation) + +- [ ] **Step 1: Install syft** + +```bash +# macOS +brew install syft + +# Or direct install +curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin +syft version +``` + +Expected: syft version printed. + +- [ ] **Step 2: Generate a baseline SBOM** + +```bash +syft packages dir:. -o spdx-json=/tmp/sbom-baseline.spdx.json --quiet +jq '.packages | length' /tmp/sbom-baseline.spdx.json +``` + +Expected: a number (count of packages found). + +- [ ] **Step 3: Verify SBOM is well-formed** + +```bash +jq '.spdxVersion, .dataLicense, (.packages | length)' /tmp/sbom-baseline.spdx.json +``` + +Expected: `"SPDX-2.3"`, `"CC0-1.0"`, and a package count. + +No commit — syft install is environmental, not repo content. + +--- + +### Task 9: Run `./scripts/gates.sh full` locally + +**Files:** none (verification only) + +- [ ] **Step 1: Start the broker** + +```bash +export AA_ADMIN_SECRET="live-test-secret-32bytes-long-ok" +./scripts/stack_up.sh +sleep 3 +curl -sf http://localhost:8080/v1/health +``` + +Expected: health endpoint returns. + +- [ ] **Step 2: Run full gates** + +```bash +./scripts/gates.sh full +``` + +Expected: all 13 gates (task + unit-tests-race + docker-build + smoke-l2.5 + sbom) pass. The parity test gate is NOT in gates.sh (it only runs in CI and as a separate script). Final line: `RESULT: PASSED`. + +If any gate fails: +- **build/vet/lint/format:** fix source code +- **contamination:** CRITICAL — investigate immediately, should never fail on clean develop +- **unit-tests / unit-tests-race:** fix failing tests +- **gosec:** triage findings per Task 2 Step 4 process +- **govulncheck:** update deps or add tech debt entry +- **go-mod-verify:** run `go mod tidy` and commit the result +- **docker-build:** check Dockerfile +- **smoke-l2.5:** broker must be running, check endpoint paths +- **sbom:** syft must be installed + +- [ ] **Step 3: Tear down** + +```bash +./scripts/stack_down.sh +``` + +- [ ] **Step 4: If there were any fixes in Step 2**, commit them with an appropriate message: + +```bash +git add -p # review each fix +git commit -m "fix(gates): address findings from first full gates.sh run" +``` + +--- + +### Task 10: Update CHANGELOG.md with M-sec entry + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Read current `CHANGELOG.md`** + +```bash +head -40 CHANGELOG.md +``` + +Note the existing format and most recent Unreleased section (if any). + +- [ ] **Step 2: Add/extend the Unreleased section** + +Add an entry under `## [Unreleased]` (create the section if missing): + +```markdown +### Added — CI/build/gates (M-sec v1) + +- `.gosec.yml` — explicit gosec configuration with documented suppressions policy. +- `.golangci.yml` — security-aware golangci-lint config (errcheck, gosec, govet, + ineffassign, staticcheck, unused, gosimple, bodyclose, misspell, gofmt, + goimports). +- `scripts/smoke/core-contract.sh` — L2.5 core contract smoke test + (issue/verify/revoke/deny out-of-scope) against a running broker. +- `scripts/test-gate-parity.sh` — enforces gate list alignment between + `scripts/gates.sh` and `.github/workflows/ci.yml`. +- `.github/workflows/ci.yml` — parallel per-gate CI pipeline on PR and push to + `develop`/`main`. +- `.github/workflows/codeql.yml` — CodeQL SAST (PR + push + weekly). +- `.github/workflows/scorecard.yml` — OpenSSF Scorecard (weekly + push to main). +- `.github/workflows/nightly.yml` — L4 full regression suite (scheduled). +- `.github/workflows/contribution-policy.yml` — auto-closes external PRs per + Decision 014 (`pull_request_target`, no PR-branch checkout). +- `.github/dependabot.yml` — weekly dependency updates for github-actions, + gomod, docker ecosystems with pinned-SHA maintenance. +- `.github/CODEOWNERS` and `.github/MAINTAINERS` — ownership and contribution + allowlist. + +### Changed — CI/build/gates (M-sec v1) + +- `scripts/gates.sh` — extended with contamination grep, `govulncheck`, + `go mod verify`, docker-build, L2.5 smoke, SBOM generation. `gosec` flipped + from warn-only to blocking. `module` mode renamed to `full` (deprecated + alias retained). Dead references to `live_test.sh` and `live_test_docker.sh` + removed. `--list-gates` flag added for parity test. +- `README.md` — added M-sec badges (build, CodeQL, Scorecard, license, Go + version, security policy). + +### Security — M-sec rationale + +Per Obsidian KB Decision 015, the M-sec scope treats CI as security evidence, +not just build verification. `govulncheck` and `gosec` are blocking gates. +Pinned action SHAs protect against action hijacking between Dependabot +rotations. The L2.5 core contract smoke verifies the product's actual +contract (issue/verify/revoke/deny) on every PR rather than a generic health +check. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): add M-sec CI/build/gates v1 entry" +``` + +--- + +### Task 11: Phase A complete — confirm clean state + +**Files:** none (verification) + +- [ ] **Step 1: Verify all Phase A commits are in place** + +```bash +git log --oneline develop..HEAD +``` + +Expected: ~7-9 commits (Tasks 2, 3, 5, 6, 7, 10 each produced one commit; Tasks 4 and 9 may have produced commits if there were fixes). + +- [ ] **Step 2: Run `./scripts/gates.sh task` one more time to confirm clean state** + +```bash +./scripts/gates.sh task +``` + +Expected: `RESULT: PASSED`. + +- [ ] **Step 3: No commit** — this is a confirmation checkpoint. + +--- + +## Phase B — GitHub Actions workflows (Tasks 12–21) + +Phase B creates all five workflow files plus Dependabot config, CODEOWNERS, MAINTAINERS. Still on `feature/ci-msec`, still not pushed. + +--- + +### Task 12: Create `.github/dependabot.yml` + +**Files:** +- Create: `.github/dependabot.yml` + +- [ ] **Step 1: Create the `.github/` directory** + +```bash +mkdir -p .github/workflows +``` + +- [ ] **Step 2: Write `.github/dependabot.yml`** + +```yaml +# Dependabot config for agentauth-core +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + # GitHub Actions — SHA maintenance for pinned workflow steps + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 3 + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + - "github-actions" + + # Go modules — direct and indirect dependencies + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 3 + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + go-direct: + dependency-type: "direct" + go-indirect: + dependency-type: "indirect" + labels: + - "dependencies" + - "go" + + # Docker base images + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore(deps)" + include: "scope" + labels: + - "dependencies" + - "docker" +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/dependabot.yml +git commit -m "chore(deps): add Dependabot config for github-actions, gomod, docker" +``` + +--- + +### Task 13: Create `.github/CODEOWNERS` and `.github/MAINTAINERS` + +**Files:** +- Create: `.github/CODEOWNERS` +- Create: `.github/MAINTAINERS` + +- [ ] **Step 1: Create `.github/CODEOWNERS`** + +``` +# Global ownership — all paths default to the maintainer. +# Per Decision 014, external contributions are not accepted, so CODEOWNERS +# primarily serves as documentation and branch-protection review enforcement. + +* @devonartis +``` + +- [ ] **Step 2: Create `.github/MAINTAINERS`** + +``` +# MAINTAINERS — allowlist for contribution-policy.yml +# +# Users listed here bypass the auto-close policy in +# .github/workflows/contribution-policy.yml. Anyone else opening a PR +# (except Dependabot and github-actions bot) gets auto-closed with a +# templated comment pointing to the issues-only contribution policy +# (Decision 014). +# +# Format: one GitHub username per line, no @ prefix, no comments inline. + +devonartis +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/CODEOWNERS .github/MAINTAINERS +git commit -m "chore(github): add CODEOWNERS and MAINTAINERS allowlist" +``` + +--- + +### Task 14: Create `.github/workflows/ci.yml` — main gate pipeline + +**Files:** +- Create: `.github/workflows/ci.yml` + +This is the largest single file in the plan. It has 13 parallel jobs plus a `gates-passed` aggregator. + +- [ ] **Step 1: Create `.github/workflows/ci.yml`** + +```yaml +name: CI + +# GATE_LIST_START +# - build +# - vet +# - lint +# - format +# - contamination +# - unit-tests +# - gosec +# - govulncheck +# - go-mod-verify +# - unit-tests-race +# - docker-build +# - smoke-l2.5 +# - sbom +# GATE_LIST_END + +on: + pull_request: + branches: [develop] + push: + branches: [develop, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Parameterized — no hardcoded owner/repo names. Rebrand-resilient per Decision 015. +permissions: + contents: read + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go build ./cmd/broker ./cmd/aactl + + vet: + name: vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go vet ./... + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --config .golangci.yml + + format: + name: format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: | + unformatted=$(gofmt -l .) + if [[ -n "$unformatted" ]]; then + echo "The following files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + contamination: + name: contamination + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + if grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/ 2>/dev/null; then + echo "FAIL: enterprise references found in core code" + exit 1 + fi + echo "PASS: no enterprise contamination" + + unit-tests: + name: unit-tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test -short -count=1 ./... + + unit-tests-race: + name: unit-tests-race + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test -race -count=1 -coverprofile=coverage.out ./... + - uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + fail_ci_if_error: false + verbose: true + + gosec: + name: gosec + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: securego/gosec@master + with: + args: '-conf .gosec.yml ./...' + + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + go-mod-verify: + name: go-mod-verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: | + go mod verify + go mod tidy + if ! git diff --exit-code go.mod go.sum; then + echo "FAIL: go.mod or go.sum changed after 'go mod tidy'" + exit 1 + fi + + docker-build: + name: docker-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t agentauth-ci:${{ github.sha }} . + + smoke-l2.5: + name: smoke-l2.5 + runs-on: ubuntu-latest + needs: [docker-build] # depends on the image existing + env: + AA_ADMIN_SECRET: live-test-secret-32bytes-long-ok # known test fixture per MEMORY.md + steps: + - uses: actions/checkout@v4 + - name: Start broker + run: | + export AA_ADMIN_SECRET="$AA_ADMIN_SECRET" + ./scripts/stack_up.sh + # Wait for health endpoint + for i in {1..30}; do + if curl -sf http://localhost:8080/v1/health >/dev/null 2>&1; then + echo "Broker up after $i seconds" + break + fi + sleep 1 + done + - name: Run L2.5 core contract smoke + run: ./scripts/smoke/core-contract.sh + - name: Teardown + if: always() + run: ./scripts/stack_down.sh + + sbom: + name: sbom + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: anchore/sbom-action@v0 + with: + path: . + format: spdx-json + output-file: sbom.spdx.json + - uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.spdx.json + retention-days: 30 + + dep-review: + name: dep-review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: moderate + + changelog: + name: changelog + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check CHANGELOG.md diff + run: | + # Skip if the PR has the 'skip-changelog' label + if echo '${{ toJson(github.event.pull_request.labels) }}' | grep -q '"skip-changelog"'; then + echo "Label 'skip-changelog' present — bypassing CHANGELOG check" + exit 0 + fi + BASE_SHA='${{ github.event.pull_request.base.sha }}' + if git diff --name-only "$BASE_SHA" HEAD | grep -q '^CHANGELOG.md$'; then + echo "PASS: CHANGELOG.md touched in this PR" + else + echo "FAIL: This PR does not touch CHANGELOG.md" + echo "Add a CHANGELOG entry or apply the 'skip-changelog' label if the PR is docs/tests-only." + exit 1 + fi + + gate-parity: + name: gate-parity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/test-gate-parity.sh + + gates-passed: + name: gates-passed + runs-on: ubuntu-latest + needs: + - build + - vet + - lint + - format + - contamination + - unit-tests + - unit-tests-race + - gosec + - govulncheck + - go-mod-verify + - docker-build + - smoke-l2.5 + - sbom + - gate-parity + if: always() + steps: + - name: Check all gates passed + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "One or more gates failed" + exit 1 + fi + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more gates were cancelled" + exit 1 + fi + echo "All gates passed" +``` + +- [ ] **Step 2: Lint the workflow locally with `actionlint` (if available)** + +```bash +if command -v actionlint &>/dev/null; then + actionlint .github/workflows/ci.yml +else + echo "actionlint not installed — skipping (install: brew install actionlint)" +fi +``` + +If `actionlint` finds issues, fix them before committing. + +- [ ] **Step 3: Run the parity test locally — it should now pass** + +```bash +./scripts/test-gate-parity.sh +``` + +Expected: `PASS: gate lists match (13 gates)`. + +If it fails, the GATE_LIST comment block in `ci.yml` and the `GATES_FULL` array in `gates.sh` must match exactly. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "feat(ci): add main CI workflow with 13 parallel gates + +Parallel per-gate jobs: build, vet, lint, format, contamination, +unit-tests, unit-tests-race, gosec, govulncheck, go-mod-verify, +docker-build, smoke-l2.5, sbom. Plus dep-review and changelog +on PR events, gate-parity enforcement, and a gates-passed +aggregator for branch protection. + +Triggers: pull_request and push to develop/main. + +All gates blocking. Coverage is uploaded informationally +(codecov, non-blocking). + +Per Obsidian KB Decision 015." +``` + +--- + +### Task 15: Create `.github/workflows/codeql.yml` + +**Files:** +- Create: `.github/workflows/codeql.yml` + +- [ ] **Step 1: Write `.github/workflows/codeql.yml`** + +```yaml +name: CodeQL + +on: + pull_request: + branches: [develop] + push: + branches: [develop, main] + schedule: + - cron: '31 7 * * 1' # weekly Monday 07:31 UTC + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [go] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/codeql.yml +git commit -m "feat(ci): add CodeQL SAST workflow + +Runs on PR and push to develop/main, weekly scheduled scan. +Uses security-extended + security-and-quality query suites. +Results populate the Security tab and the CodeQL badge." +``` + +--- + +### Task 16: Create `.github/workflows/scorecard.yml` + +**Files:** +- Create: `.github/workflows/scorecard.yml` + +- [ ] **Step 1: Write `.github/workflows/scorecard.yml`** + +```yaml +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '25 3 * * 2' # weekly Tuesday 03:25 UTC + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ossf/scorecard-action@v2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - uses: actions/upload-artifact@v4 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/scorecard.yml +git commit -m "feat(ci): add OpenSSF Scorecard workflow + +Runs on push to main, weekly schedule, and branch protection changes. +Publishes results to the OpenSSF Scorecard badge and uploads SARIF +to the Security tab. + +Informational only — not a required check. Scorecard's value signal +appears when the repo flips public." +``` + +--- + +### Task 17: Create `.github/workflows/nightly.yml` — L4 full regression + +**Files:** +- Create: `.github/workflows/nightly.yml` + +- [ ] **Step 1: Write `.github/workflows/nightly.yml`** + +```yaml +name: Nightly full regression + +on: + schedule: + - cron: '17 5 * * *' # daily 05:17 UTC + workflow_dispatch: # manual trigger for ad-hoc runs + +permissions: + contents: read + issues: write # to open issues on failure + +jobs: + regression: + name: L4 full regression + runs-on: ubuntu-latest + env: + AA_ADMIN_SECRET: live-test-secret-32bytes-long-ok + steps: + - uses: actions/checkout@v4 + with: + ref: develop + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build broker image + run: | + export AA_ADMIN_SECRET="$AA_ADMIN_SECRET" + docker compose build + + - name: Run full regression suite + id: regression + run: ./scripts/gates.sh regression + continue-on-error: true + + - name: Upload evidence on failure + if: steps.regression.outcome == 'failure' + uses: actions/upload-artifact@v4 + with: + name: regression-evidence-${{ github.run_id }} + path: tests/**/evidence/ + retention-days: 14 + + - name: Open issue on failure + if: steps.regression.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const run_url = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + const short_sha = context.sha.substring(0, 7); + await github.rest.issues.create({ + owner, + repo, + title: `Nightly regression failed — ${short_sha}`, + body: [ + '# Nightly L4 regression failure', + '', + `**Commit:** \`${short_sha}\``, + `**Branch:** develop`, + `**Workflow run:** ${run_url}`, + '', + 'The nightly full regression suite failed. Evidence uploaded as workflow artifact.', + '', + 'Triage steps:', + '1. Download the `regression-evidence-${{ github.run_id }}` artifact', + '2. Identify which batch failed (`scripts/gates.sh regression` output)', + '3. Reproduce locally: `./scripts/gates.sh regression`', + '4. Open a fix branch if the failure is real, or close this issue if flaky', + '', + '_Auto-created by `.github/workflows/nightly.yml`_', + ].join('\n'), + labels: ['regression', 'nightly', 'needs-triage'], + }); + + - name: Fail workflow if regression failed + if: steps.regression.outcome == 'failure' + run: exit 1 +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/nightly.yml +git commit -m "feat(ci): add nightly L4 full regression workflow + +Runs ./scripts/gates.sh regression nightly against develop. +On failure: uploads evidence artifacts, opens a GitHub issue +tagged 'regression/nightly/needs-triage', fails the workflow. + +Informational only — does not block in-flight PRs per Decision +015. The 24-hour lag is acceptable because L2.5 smoke catches +the core contract regressions on every PR." +``` + +--- + +### Task 18: Create `.github/workflows/contribution-policy.yml` + +**Files:** +- Create: `.github/workflows/contribution-policy.yml` + +**CRITICAL:** this workflow uses `pull_request_target`. It MUST NEVER check out the PR branch. See Decision 015 for the security rationale. + +- [ ] **Step 1: Write `.github/workflows/contribution-policy.yml`** + +```yaml +name: Contribution Policy + +# SECURITY: This workflow uses pull_request_target, which runs in the base +# branch context with write permissions. This is required to close PRs. The +# workflow MUST NEVER check out the PR branch — checking out untrusted PR code +# with write tokens is a supply-chain compromise vector. +# +# This workflow only reads metadata (PR author, PR number) via the GitHub API. +# It does NOT run actions/checkout. + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + check-author: + name: Enforce contribution policy + runs-on: ubuntu-latest + steps: + - name: Check PR author against MAINTAINERS + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const author = pr.user.login; + const pr_number = pr.number; + + // Always-exempt bots + const bot_exempt = ['dependabot[bot]', 'github-actions[bot]', 'renovate[bot]']; + if (bot_exempt.includes(author)) { + core.info(`Bot author ${author} exempt — no action`); + return; + } + + // Check if author has write access to the repo + try { + const { data: perms } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: author, + }); + if (['admin', 'maintain', 'write'].includes(perms.permission)) { + core.info(`Author ${author} has ${perms.permission} access — exempt`); + return; + } + } catch (e) { + // Not a collaborator — continue to MAINTAINERS check + } + + // Check MAINTAINERS file via GitHub API (not via checkout) + let maintainers = []; + try { + const { data: file } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/MAINTAINERS', + ref: context.payload.pull_request.base.ref, + }); + const content = Buffer.from(file.content, 'base64').toString('utf-8'); + maintainers = content + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + } catch (e) { + core.warning(`Could not read MAINTAINERS file: ${e.message}`); + } + + if (maintainers.includes(author)) { + core.info(`Author ${author} in MAINTAINERS — exempt`); + return; + } + + // Not exempt — enforce policy + core.info(`Author ${author} not exempt — closing PR per Decision 014`); + + const policy_comment = [ + `Hi @${author}, thank you for your interest in AgentAuth!`, + '', + 'Per our contribution policy ([Decision 014](https://github.com/' + owner + '/' + repo + '/blob/develop/CONTRIBUTING.md)), AgentAuth does not accept external code contributions at this time — including bug fixes.', + '', + 'We actively welcome:', + '- **Bug reports** — please [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new)', + '- **Feature requests** — same place', + '- **Security vulnerabilities** — please see [SECURITY.md](https://github.com/' + owner + '/' + repo + '/blob/develop/SECURITY.md) for the responsible disclosure process', + '', + 'This policy exists because we\'re still defining our contribution workflow (test plan, merge process, review gates). Opening to PRs before that\'s ready would mean every PR becomes a coaching session, which wouldn\'t be fair to you or to us. The policy will be revisited once the workflow is documented and tested.', + '', + 'This PR will be closed automatically. Please don\'t take it personally — the bot is enforcing policy, not judging your work. We genuinely appreciate the interest.', + '', + '_Auto-enforced by `.github/workflows/contribution-policy.yml`_', + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: policy_comment, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr_number, + state: 'closed', + }); +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/contribution-policy.yml +git commit -m "feat(ci): add contribution policy enforcement workflow + +Auto-closes PRs from non-maintainers with a templated comment +pointing to the issues-only contribution policy (Decision 014). + +Security: uses pull_request_target for write permissions but +NEVER checks out the PR branch. Reads MAINTAINERS file via +GitHub API only. Exempts dependabot, github-actions bot, and +users with write access or listed in .github/MAINTAINERS." +``` + +--- + +### Task 19: actionlint all workflows (if available) + +**Files:** none (verification) + +- [ ] **Step 1: Run actionlint on all workflows** + +```bash +if command -v actionlint &>/dev/null; then + actionlint .github/workflows/*.yml +else + echo "actionlint not installed — skipping. Install: brew install actionlint" +fi +``` + +If actionlint is installed, fix any reported issues before moving on. + +- [ ] **Step 2: Run the parity test one more time** + +```bash +./scripts/test-gate-parity.sh +``` + +Expected: `PASS: gate lists match (13 gates)`. + +- [ ] **Step 3: Commit any fixes** + +Skip if no issues were found. + +--- + +### Task 20: Phase B checkpoint — verify all files present + +**Files:** none (verification) + +- [ ] **Step 1: Confirm all Phase B files exist** + +```bash +ls -la .github/workflows/ +ls -la .github/dependabot.yml .github/CODEOWNERS .github/MAINTAINERS +ls -la .gosec.yml .golangci.yml +ls -la scripts/smoke/core-contract.sh scripts/test-gate-parity.sh +``` + +Expected: all files present, scripts executable. + +- [ ] **Step 2: Review commit history for Phase B** + +```bash +git log --oneline develop..HEAD +``` + +Expected: clean, logical commit progression through Phase A + Phase B. + +--- + +## Phase C — Pin SHAs, push, iterate (Tasks 21–25) + +--- + +### Task 21: Install actionlint if not already (recommended) + +**Files:** none (tool install) + +- [ ] **Step 1: Install actionlint** + +```bash +# macOS +brew install actionlint + +# Or direct +bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) +actionlint -version +``` + +- [ ] **Step 2: Run against all workflows** + +```bash +actionlint .github/workflows/*.yml +``` + +Expected: no errors. Fix any reported issues and commit with `chore(ci): fix actionlint findings`. + +--- + +### Task 22: Pin all action SHAs + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/codeql.yml` +- Modify: `.github/workflows/scorecard.yml` +- Modify: `.github/workflows/nightly.yml` +- Modify: `.github/workflows/contribution-policy.yml` + +**Why:** Tags are mutable; a compromised action can change what its tag points to. Pinning to a 40-char SHA means Dependabot controls when SHAs rotate, and the rotation produces a visible PR. + +- [ ] **Step 1: Collect the SHAs** + +For each action below, visit the releases page, find the latest stable release, and record the full commit SHA. Commands to help (requires `gh` CLI): + +```bash +get_latest_sha() { + local action=$1 + gh api "repos/$action/releases/latest" --jq '.target_commitish' 2>/dev/null || \ + gh api "repos/$action/git/refs/tags/$(gh api repos/$action/releases/latest --jq .tag_name)" --jq '.object.sha' +} + +# Actions used in this plan: +for action in \ + actions/checkout \ + actions/setup-go \ + actions/upload-artifact \ + actions/github-script \ + actions/dependency-review-action \ + golangci/golangci-lint-action \ + securego/gosec \ + github/codeql-action \ + ossf/scorecard-action \ + anchore/sbom-action \ + codecov/codecov-action ; do + sha=$(get_latest_sha "$action") + echo "$action@$sha" +done +``` + +Record the SHAs in a temp file for reference. + +- [ ] **Step 2: Replace tags with SHAs in every workflow file** + +For each `uses:` line, replace the tag reference with the SHA and add a `# v` comment. Example: + +```yaml +# Before: +- uses: actions/checkout@v4 + +# After: +- uses: actions/checkout@<40-char-sha> # v4.1.7 +``` + +Apply this to **every** `uses:` line across all five workflow files. + +- [ ] **Step 3: Re-run actionlint to confirm nothing broke** + +```bash +actionlint .github/workflows/*.yml +``` + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ +git commit -m "chore(ci): pin all action SHAs for supply chain safety + +All actions referenced by their 40-char commit SHA with a +version comment. Dependabot will maintain these weekly per +.github/dependabot.yml. + +Protects against action tag hijacking between Dependabot +rotations. Standard discipline for security-adjacent repos +per Obsidian KB Decision 015." +``` + +--- + +### Task 23: Push `feature/ci-msec` and observe the first CI run + +**Files:** none (git push + observation) + +- [ ] **Step 1: Confirm clean local state** + +```bash +git status --short +./scripts/gates.sh task +``` + +Expected: working tree clean, all task gates pass. + +- [ ] **Step 2: Push the branch** + +```bash +git push -u origin feature/ci-msec +``` + +- [ ] **Step 3: Watch the CI run** + +```bash +gh run watch +# or +gh run list --branch feature/ci-msec --limit 5 +``` + +- [ ] **Step 4: Expect failures on the first run** + +Common first-run issues: +- **`smoke-l2.5` fails because Docker takes longer to start than the 30s wait** → extend the wait loop in ci.yml +- **`docker-build` fails because Dockerfile references cache mounts the runner doesn't have** → adjust Dockerfile or CI step +- **`golangci-lint-action` version mismatch** → update the `version:` field +- **`gosec` action complains about config path** → check the `args:` path is relative to repo root +- **`codecov` upload fails on private repo** → either add `CODECOV_TOKEN` secret or set `fail_ci_if_error: false` (already set in the plan) +- **`go-mod-verify` fails because CI's Go toolchain is slightly different** → may need to commit `go.mod` changes +- **`contribution-policy` fires on our own PR when we open it in Task 25** → add the branch author to MAINTAINERS before opening the PR (already done in Task 13) +- **`smoke-l2.5` fails because the endpoint paths don't match** → update `scripts/smoke/core-contract.sh` to match `docs/api.md` + +Iterate until CI is green on `feature/ci-msec`. Each fix is a new commit on the branch. + +- [ ] **Step 5: Do not squash** — keep the iteration history for now. It'll be squashed in Task 25 when opening the PR. + +--- + +### Task 24: Triage and fix CI-only issues + +**Files:** whichever files need fixing + +- [ ] **Step 1: For each failing job, read the logs** + +```bash +gh run view --log-failed +``` + +- [ ] **Step 2: Fix the root cause** + +Do NOT paper over issues: +- If `gosec` finds a real issue, fix the code or suppress it with justification +- If `smoke-l2.5` endpoint path is wrong, update the smoke script to match actual API +- If `docker-build` fails, fix the Dockerfile +- If `codecov` is spammy, tighten its config + +- [ ] **Step 3: Commit fix, push, re-run** + +```bash +git add +git commit -m "fix(ci): " +git push +gh run watch +``` + +- [ ] **Step 4: Repeat until all jobs green** + +Be patient. Expect 3-8 iterations on first rollout. + +--- + +### Task 25: Open PR from `feature/ci-msec` to `develop` + +**Files:** none (PR creation) + +- [ ] **Step 1: Confirm CI is green on the feature branch** + +```bash +gh run list --branch feature/ci-msec --limit 3 +``` + +Expected: latest run is green (all gates passed). + +- [ ] **Step 2: Create the PR** + +```bash +gh pr create --base develop --head feature/ci-msec \ + --title "feat(ci): M-sec CI/build/gates v1" \ + --body "$(cat <<'EOF' +## Summary + +Implements the M-sec CI/build/gates pipeline per Obsidian KB Decision 015 and the design doc at `.plans/designs/2026-04-10-ci-build-gates-msec-design.md`. + +**What changes:** +- Five GitHub Actions workflows: `ci.yml` (13 parallel gates), `codeql.yml`, `scorecard.yml`, `nightly.yml`, `contribution-policy.yml` +- `scripts/gates.sh` extended with M-sec gates; `gosec` flipped from warn to blocking +- New: `scripts/smoke/core-contract.sh` (L2.5 core contract smoke), `scripts/test-gate-parity.sh` +- New: `.gosec.yml`, `.golangci.yml`, `.github/dependabot.yml`, `.github/CODEOWNERS`, `.github/MAINTAINERS` +- `CHANGELOG.md` updated under Unreleased +- All action references pinned to 40-char SHAs per Decision 015 supply-chain discipline + +**What this is NOT:** +- No release automation, no GHCR publish, no SLSA provenance (deferred to a later cycle) +- No pre-commit hooks (separate smaller cycle) +- No README badge updates (Task 26 post-merge) + +**Rationale:** Decision 015 lays out why M-sec (not generic M) is the right scope for a credential broker, and why CI must exist before the AgentWrit rebrand lands. + +## Test plan + +- [x] `./scripts/gates.sh task` passes locally +- [x] `./scripts/gates.sh full` passes locally against a `stack_up.sh` broker +- [x] `./scripts/test-gate-parity.sh` passes +- [x] `./scripts/smoke/core-contract.sh` passes against a live broker (9/9 steps) +- [x] All CI gates green on `feature/ci-msec` before opening this PR +- [ ] Merge this PR +- [ ] Configure branch protection on `develop` (Task 27) +- [ ] Merge `develop` → `main` via `strip_for_main.sh` +- [ ] Configure branch protection on `main` (Task 30) +- [ ] 7-day observation: first Dependabot PR, first nightly run + +## Related + +- Obsidian KB Decision 015: CI/Gates Strategy — Security-First, Rebrand-Resilient +- Obsidian KB Decision 014: No External Contributions (enforced by `contribution-policy.yml`) +- Obsidian KB Decision 013: AgentWrit Rebrand (this CI unblocks the rebrand PR) +- Design doc: `.plans/designs/2026-04-10-ci-build-gates-msec-design.md` +EOF +)" +``` + +- [ ] **Step 3: Verify CI runs on the PR** + +```bash +gh pr checks +``` + +Expected: all checks pending, then green within ~5-10 minutes. + +--- + +## Phase D — Merge, protect, observe (Tasks 26–31) + +--- + +### Task 26: Merge the PR to develop + +**Files:** none (merge operation) + +- [ ] **Step 1: Verify all checks green** + +```bash +gh pr checks +``` + +- [ ] **Step 2: Merge** + +Choose squash merge to collapse the iteration history into one clean commit: + +```bash +gh pr merge --squash --delete-branch +``` + +Expected: PR closed, branch deleted locally and remotely. + +- [ ] **Step 3: Pull latest develop** + +```bash +git checkout develop +git pull origin develop +``` + +- [ ] **Step 4: Verify CI runs on push to develop** + +```bash +gh run list --branch develop --limit 3 +``` + +Expected: new run started by the merge commit. + +--- + +### Task 27: Configure branch protection on `develop` + +**Files:** none (GitHub API calls via `gh`) + +- [ ] **Step 1: Wait for the develop CI run to complete green** + +```bash +gh run watch +``` + +- [ ] **Step 2: Apply branch protection via `gh api`** + +```bash +OWNER=$(gh repo view --json owner --jq .owner.login) +REPO=$(gh repo view --json name --jq .name) + +gh api -X PUT "repos/$OWNER/$REPO/branches/develop/protection" \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "contexts": ["gates-passed", "analyze"] + }, + "enforce_admins": false, + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false, + "required_approving_review_count": 0 + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true +} +EOF +``` + +Note: `required_approving_review_count: 0` because the repo has a single maintainer. Increase if the team grows. + +- [ ] **Step 3: Verify protection is active** + +```bash +gh api "repos/$OWNER/$REPO/branches/develop/protection" | jq '.required_status_checks.contexts' +``` + +Expected: `["gates-passed", "analyze"]`. + +--- + +### Task 28: Merge `develop` → `main` via strip script + +**Files:** `strip_for_main.sh` runs + +- [ ] **Step 1: Check out main and fast-forward** + +```bash +git checkout main +git pull --ff-only origin main +git merge --no-ff develop -m "Merge develop → main: M-sec CI/build/gates v1" +``` + +- [ ] **Step 2: Run strip_for_main.sh** + +```bash +./scripts/strip_for_main.sh +``` + +Expected: files in FLOW.md, MEMORY.md, .plans/, adr/, tests/, TECH-DEBT.md, etc. are removed from the working tree on main. + +- [ ] **Step 3: Review what was stripped** + +```bash +git status +``` + +Ensure the strip removed the expected files and didn't touch anything in `.github/`, `scripts/`, `.gosec.yml`, `.golangci.yml`, or `internal/`/`cmd/`. + +- [ ] **Step 4: Commit the strip** + +```bash +git add -A +git commit -m "chore: strip dev files for main merge" +``` + +- [ ] **Step 5: Push main** + +```bash +git push origin main +``` + +- [ ] **Step 6: Verify CI runs on push to main** + +```bash +gh run list --branch main --limit 3 +``` + +Expected: new run on main. Wait for it to go green. + +--- + +### Task 29: Configure branch protection on `main` + +**Files:** none + +- [ ] **Step 1: Wait for the main CI run to complete green** + +```bash +gh run watch +``` + +- [ ] **Step 2: Apply branch protection** + +```bash +gh api -X PUT "repos/$OWNER/$REPO/branches/main/protection" \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "contexts": ["gates-passed", "analyze"] + }, + "enforce_admins": false, + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false, + "required_approving_review_count": 0 + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true +} +EOF +``` + +- [ ] **Step 3: Verify** + +```bash +gh api "repos/$OWNER/$REPO/branches/main/protection" | jq '.required_status_checks.contexts' +``` + +--- + +### Task 30: Add badges to README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Read current README** + +```bash +head -30 README.md +``` + +Identify where badges should go (usually right after the title / description). + +- [ ] **Step 2: Add the six M-sec badges** + +Insert this block after the README title / intro: + +```markdown +[![Build](https://github.com/devonartis/agentauth/actions/workflows/ci.yml/badge.svg)](https://github.com/devonartis/agentauth/actions/workflows/ci.yml) +[![CodeQL](https://github.com/devonartis/agentauth/actions/workflows/codeql.yml/badge.svg)](https://github.com/devonartis/agentauth/actions/workflows/codeql.yml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/devonartis/agentauth/badge)](https://securityscorecards.dev/viewer/?uri=github.com/devonartis/agentauth) +[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) +[![Go Version](https://img.shields.io/github/go-mod/go-version/devonartis/agentauth)](go.mod) +[![Security Policy](https://img.shields.io/badge/security-policy-brightgreen)](SECURITY.md) +``` + +**NOTE on badge URLs:** These are the only places where the rebrand will need to sed — badge URLs contain `devonartis/agentauth` literals because markdown doesn't interpolate. Acceptable — documented in Decision 015. + +- [ ] **Step 3: Commit** + +```bash +git checkout develop +git add README.md +git commit -m "docs(readme): add M-sec badges (build, CodeQL, Scorecard, license, Go, security)" +git push origin develop +``` + +- [ ] **Step 4: Verify the CI runs triggered by this push stay green** + +```bash +gh run watch +``` + +--- + +### Task 31: Observation period — confirm nightly + Dependabot fire + +**Files:** none (monitoring) + +- [ ] **Step 1: Wait for the next scheduled nightly run** + +Next scheduled: 05:17 UTC the next day. Check: + +```bash +gh run list --workflow=nightly.yml --limit 3 +``` + +Expected: nightly run completes (green or red doesn't matter — the workflow should fire). + +- [ ] **Step 2: Wait for the first Dependabot PR** + +Next scheduled: Monday 06:00 UTC. Check: + +```bash +gh pr list --label dependencies +``` + +Expected: Dependabot has opened PRs for any available github-actions / gomod / docker updates. + +- [ ] **Step 3: Review and merge any Dependabot PRs that pass CI** + +For each Dependabot PR: +```bash +gh pr checks +gh pr merge --squash +``` + +- [ ] **Step 4: Confirm the contribution-policy workflow is active** + +No external PRs are likely during the observation window, so this is best-effort. If a test is desired, ask a maintainer collaborator to open a throwaway PR from a non-maintainer account and confirm it gets auto-closed with the templated comment. + +--- + +## Post-rollout — Wrap-up + +After Task 31, the M-sec CI/build/gates v1 is complete. Final handoff: + +- [ ] Update `FLOW.md` with a `## 2026-04-XX — M-sec CI/build/gates v1 MERGED` entry (decision + what shipped + next priority) +- [ ] Update `MEMORY.md` with any lessons learned from the rollout (what broke, what surprised, what the user corrected) +- [ ] Consider writing an ADR in `adr/` for the specific technical architecture (referencing Decision 015). Suggested: `adr/015-ci-gates-architecture.md` — captures Option B, the parity test mechanism, the L2.5 contract, and the pinned-SHA discipline. This is the "how" ADR that complements Decision 015's "why." +- [ ] Close out this devflow cycle — mark complete in any tracker, and identify the next priority (likely the AgentWrit rebrand cycle, now unblocked by CI existing) + +--- + +## Self-review checklist (run before handing off) + +- [ ] Every task has exact file paths +- [ ] Every code block is complete — no "TBD", no "similar to above" +- [ ] Every command has an expected output or success criterion +- [ ] No hardcoded owner/repo strings in workflow files (parameterized via `${{ github.repository }}` or `${{ github.repository_owner }}`) +- [ ] All action references use `@v` tags AT PLAN TIME but are pinned to SHAs in Task 22 BEFORE first push +- [ ] CHANGELOG.md entry exists and names all files/changes +- [ ] Decision 015 is referenced in the PR body +- [ ] Branch protection references the `gates-passed` aggregator (not individual job names) to survive gate list changes + +--- + +**End of plan.** Total: 31 tasks across 4 phases. Estimated implementation time is deliberately not included — this is a plan, not a schedule. diff --git a/CHANGELOG.md b/CHANGELOG.md index 926b660..aca0b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — CI/build/gates (M-sec v1) + +- **`.gosec.yml`** — explicit gosec configuration with documented rule + exclusions (G117, G304, G101) rationalized for a credential broker's API + surface. Every excluded rule carries a reviewer-auditable rationale. +- **`.golangci.yml`** — security-aware `golangci-lint` config (errcheck, + gosec, govet, ineffassign, staticcheck, unused, gosimple, bodyclose, + misspell, gofmt, goimports) with tuned govet subchecks (fieldalignment + and shadow disabled with rationale) and mirrored gosec excludes. +- **`scripts/smoke/core-contract.sh`** — L2.5 core contract smoke test. + 10-step verification (health, admin auth, launch token, challenge, + Ed25519 challenge-response register, JWT structure, validate-accepted, + revoke, validate-rejected, out-of-scope denied) against a running + broker. Uses `python3 + cryptography` for the Ed25519 signing step. +- **`scripts/test-gate-parity.sh`** — enforces gate list alignment + between `scripts/gates.sh --list-gates` and `.github/workflows/ci.yml` + `GATE_LIST_START/END` block. Prevents silent drift. +- **`syft scan` baseline** — SBOM generation integrated into the local + `gates.sh full` pipeline (SPDX-2.3, 27 packages at baseline). + +### Changed — CI/build/gates (M-sec v1) + +- **`scripts/gates.sh`** — extended from 4 gates to 13. New blocking + gates: `contamination` (enterprise refs grep), `govulncheck` (stdlib + and dependency vulnerabilities), `go-mod-verify` (module integrity + + tidy drift), `vet`, `format`, plus `full`-mode-only: `unit-tests-race`, + `docker-build`, `smoke-l2.5`, `sbom`. `gosec` flipped from warn-only + to blocking. `module` renamed to `full` (deprecated alias retained). + Dead references to `live_test.sh`/`live_test_docker.sh` removed. + `golangci-lint` and `gosec` are now required (no fallback). Added + `--list-gates` for parity enforcement. Honors `BROKER_URL` for + smoke-l2.5 on non-default ports. +- **`TECH-DEBT.md`** — recorded TD-VUL-001..004 (four Go stdlib CVEs + fixed by bumping `go.mod` toolchain from `go1.25.7` to `go1.25.9`, + scheduled for landing at the first CI push). + +### Fixed — CI/build/gates (M-sec v1) + +- **gofmt drift** — 24 pre-existing gofmt-dirty files normalized in a + single style commit. No behavior change. Surfaced by adding `format` + as a blocking gate. +- **`internal/keystore/parseKey`** — defensive type-assertion on + `priv.Public().(ed25519.PublicKey)` to satisfy `errcheck + check-type-assertions`. Unreachable on the happy path. +- **`internal/mutauth/heartbeat.sweep`** — heartbeat auto-revoke + failures are now logged via `obs.Warn` instead of being silently + dropped. Previously `_, _ = h.revSvc.Revoke(...)` was followed by an + unconditional "agent auto-revoked" log line, even when the revocation + actually failed. +- **`cmd/aactl/client`** — `json.Marshal` and `io.ReadAll` errors are + now propagated as wrapped errors instead of being discarded. Affects + `authenticate()` (two sites) and `doPostWithToken()`. +- **`internal/store/sql_store.QueryAuditEvents`** — documented `#nosec + G202` on the audit query SELECT, explaining why the fragment + concatenation is safe (fixed template, parameterized values). +- **`internal/admin/admin_svc_test.TestLaunchTokenRecord_SpecCompliance`** — + clarified the exhaustive-literal intent in a doc comment and silenced + `govet unusedwrite` with `_ = rec`. + ### Added **Security hardening** diff --git a/FLOW.md b/FLOW.md index ef36967..dc2dac8 100644 --- a/FLOW.md +++ b/FLOW.md @@ -392,3 +392,112 @@ CC v4 plan fully executed. Develop → main merge fast-forwarded, strip_for_main 6. **`demo/.env.example`** in SDK repo — has hardcoded vLLM URL (`spark-3171`), needs generic placeholder 7. **GitHub public flip** — after domain, after all docs clean, after external security audit + +--- + +## 2026-04-10 — ADR/Decision split, skill build, branch cleanup, merge to develop + +### Decision: Split technical ADRs from non-technical Decisions + +ADRs (code-level) live in repo `adr/` on develop, stripped from main. Non-technical decisions (business, marketing, licensing, strategy, rebrand, tooling) live in Obsidian KB only at parent-project level. Classification principle: **if the deployed code changes because of this decision, it's an ADR. If not, it's a Decision.** Rebrands, licensing, release strategy, repo renames → always Decision. Fork points, code standards, acceptance tests, gitflow → always ADR. + +### Action: Restructured repo decisions/ → adr/ + +- Repo `decisions/` renamed to `adr/` +- 6 ADRs kept in repo: 001 Fork point, 003 GitFlow, 004 Clone not copy, 007 Code comments, 009 Acceptance tests, 011 Develop/main discipline +- 6 non-ADR files removed from repo (002, 005, 006, 008, 010, 012) — they live in Obsidian KB only +- `strip_for_main.sh` updated: `adr/` now stripped from main merge +- Gaps in numbering (002, 005, 006...) are meaningful — indicate which type went where + +### Action: Built `/obsidian:decision` skill + +Records decisions to repo (ADRs) or Obsidian KB (Decisions) with wikilinks, backlinks, scope tracking, daily note journal entry. Three iterations: first draft, skill-creator audit, rewrite with "Why these rules exist" framing + validation step. Config at `~/.claude/obsidian-projects.json` maps 4 AgentAuth repos to KB paths. Installed `obsidian-agent` globally (44 tools) for vault diagnostics (search, backlinks, broken-links). + +### Decision: smart-search is the first tool for vault queries + +Added 2-line pointer in global `~/.claude/CLAUDE.md` with full reference at `~/.claude/skills/obsidian:decision/references/obsidian-agent-commands.md`. `smart-search` is the BM25-ranked default, falls back to grep only when MCP tool unavailable. + +### Decision 014: No external contributions, bug reports only (project-wide) + +All AgentAuth repos accept no external code contributions. No PRs, not even bug fixes. External people can file bug reports and feature requests as issues. Public visibility and accepting contributions are separate decisions — the repo may go public under AGPL without opening to PRs. Exit criteria: documented test plan + merge plan + contribution guide tested with at least one non-maintainer. Decision file at `KB/10-Projects/AgentAuth/decisions/014-no-external-contributions.md`. + +### Action: Root cleanup — deleted 7 stray files/folders + +Deleted `DEVELOPMENT_STANDARDS.md`, `MiniMaxPythonSDK_REVIEW.md`, `SDK_BLUEPRINT.md`, `GeminiReview/`, `docs/python-sdk-design.md`, `docs/python-sdk-design-v2.md`, `docs/python-sdk-design-final.md`. All were April 5-6 scratch files that ended up in the wrong repo. + +### Action: Merged docs/readme-sdk-demo to develop + +Merge commit `511dde6`. Includes: README SDK section (pending re-review — user skeptical of value), CONTRIBUTING rewrite (now inconsistent with Decision 014 — needs follow-up), ADR directory structure, SECURITY.md corrections, CODE_OF_CONDUCT.md, root cleanup, scripts/strip_for_main.sh update. Pushed to origin. + +### Action: Branch cleanup — 15 branches deleted + +All B0-B6 migration cherry-pick branches deleted (sidecar-removal, p0-persistent-key, p1-admin-secret, sec-l1, sec-l2a, sec-l2b, sec-a1), plus docs/readme-sdk-demo (merged), develop-harness-backup (already cherry-picked), devin/1775212397-add-wiki-pages (Devin PR, duplicated existing docs, bad job), whitesource/configure (auto-scanner branch), fix/app-launch-tokens-endpoint and fix/docs-overhaul (merged weeks ago). Repo now: develop + main only, locally and remotely. + +### Status: develop ahead of main + +`511dde6` on develop, not yet merged to main. Strip script will remove `adr/` on next develop → main merge. + +--- + +### What's Next (2026-04-10) + +**Priority: CI, build, and gates — done professionally.** + +The repo needs a real CI/build/gates setup before any public work. Current gates (`scripts/gates.sh`) are local-only and not wired into CI. Next session, brainstorm and spec this out via devflow: + +**CI pipeline (GitHub Actions, runs on every push to develop):** +- **Build** — `go build ./...` both binaries (broker + aactl) +- **Unit tests** — `go test ./... -race` +- **Lint** — `golangci-lint` (staticcheck, errcheck, gosec, revive minimum) +- **Formatting** — `gofmt -l` must return empty +- **Contamination check** — grep `hitl|approval|oidc|federation|cloud|sidecar` in `internal/` and `cmd/` must return nothing +- **Security scan** — `gosec` + `govulncheck` against go.mod +- **Docker build** — multi-stage build, image builds cleanly +- **Acceptance smoke** — at least one acceptance story per feature runs against Docker +- **SBOM generation** — `syft` SPDX output as artifact + +**Gates (local `scripts/gates.sh` extended, mirrored in CI):** +- G1 Build, G2 Unit tests, G3 Contamination, G4 Docker build, G5 Lint, G6 Smoke, G7 Security scan +- Each gate a separate step, so CI shows which gate failed +- `./scripts/gates.sh task` runs fast gates (G1-G3, G5) for dev iteration +- `./scripts/gates.sh full` runs everything including Docker and smoke + +**Release automation:** +- Tagged releases trigger release workflow +- Automated `CHANGELOG.md` section from commit messages since previous tag +- Multi-arch Docker image publish to GHCR (amd64 + arm64) +- SBOM attached to release +- GitHub Release notes auto-generated + +**Contribution gate (per Decision 014):** +- PRs from non-maintainers get auto-closed with a comment pointing to the issues-only policy +- Issue templates for bug reports and feature requests +- No "good first issue" or "help wanted" labels yet + +**Pre-commit hooks (develop-side):** +- Extend existing `.githooks/pre-commit` to run `gofmt -l`, `go vet`, contamination grep +- Fast-fail before the commit lands + +**Still carried over from 2026-04-08 (lower priority than CI):** +- CONTRIBUTING.md update per Decision 014 +- README SDK section decision +- Domain placeholder emails (per Decision 013 → agentwrit.com) +- `docs/api/openapi.yaml` license fix +- `docs/getting-started-developer.md` SDK link + +**First concrete action next session:** devflow → brainstorm CI/build/gates scope → spec → plan → execute. This is a feature, not cleanup, so the full devflow cycle applies. + +--- + +## 2026-04-10 — M-sec CI/build/gates — design + plan ready + +### Decision: CI before rebrand, M-sec scope (not generic M) + +Why in Obsidian KB Decision 015. Council + acceptance tests bypassed for this infrastructure cycle. + +### Action: Wrote design doc + implementation plan + +- `.plans/designs/2026-04-10-ci-build-gates-msec-design.md` — architecture +- `.plans/specs/2026-04-10-ci-build-gates-msec-plan.md` — 31 tasks across 4 phases + +### Status: Ready to execute — next cuts `feature/ci-msec` (Task 1 of plan) diff --git a/MEMORY.md b/MEMORY.md index 780c8ee..b089dae 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,6 +2,51 @@ ## Recent Lessons (last 3 sessions — older archived to MEMORY_ARCHIVE.md) +### ADR vs Decision split, skill build, and branch cleanup (2026-04-10) + +**What happened:** Long session that restructured how decisions get captured and cleaned up months of branch debt. + +**The ADR vs Decision distinction (golden):** Earlier in the day the user noticed the `decisions/` directory in the repo was a grab bag — technical choices like "fork point" mixed with business choices like "open-core model" and "AGPL license." Restructured into two tracks: +- **ADRs** (`adr/` in the repo) — technical decisions about the code. Stay with the code on `develop`, stripped from `main`. 6 files. +- **Decisions** (Obsidian KB only, never in any repo) — strategy, licensing, business, marketing, cross-project thinking. 8 files at parent-project level in KB. + +**The classification principle:** "If the deployed code changes because of this decision, it's an ADR. If not, it's a Decision." One sentence. Everything else follows. This replaced a 13-row lookup table as the primary classification rule — the table became examples of the principle, not the rule itself. Rebrand = marketing (code unchanged) = Decision. License = legal file change (wire format unchanged) = Decision. Fork point = defines what code exists = ADR. Code comment standard = changes how code looks = ADR. + +**Built `/obsidian:decision` skill in three passes:** +1. First draft: rigid MUSTs, 9 hard rules, surface-all-decisions at start of every run +2. Audit by skill-creator agent exposed the failures — heavy-handed MUSTs conflict with the skill-creator guidance to "explain the why," classification was mechanical rather than principle-first, no validation step +3. Rewrite: "Why these rules exist" section replaces rigid block (each rule explains the failure it prevents), classification leads with principle, added `validate` step after writes that checks frontmatter fields + array types + wikilink resolution + +**Then user pushed back on surface-all-decisions.** "Showing all decisions upfront burns credits and I don't need that — I can ask when I need it." Rewrote `surface_context` → `check_duplicate`: only runs a cheap title grep when topic overlap is suspected. Full listing only on explicit ask. Reading a specific decision (e.g. "what did we decide about licensing?") = grep for it, read the one file. **Lesson: read on demand, not on spec.** + +**Obsidian-agent is the first tool for vault queries, not grep.** Installed obsidian-agent globally (44 tools). For vault lookup, `smart-search` (BM25 ranking) is the default, not grep. Added a short pointer in global `~/.claude/CLAUDE.md` (2 lines) with full reference at `~/.claude/skills/obsidian:decision/references/obsidian-agent-commands.md`. First attempt at the CLAUDE.md section was ~40 lines — user called it out: "way too much content for global, god forbid every entry was like this you write a book claude.md would not be optimized." Trimmed to 2 lines. **Lesson: global instructions stay lean, details live in reference files.** + +**Decision 014 captured using the new skill end-to-end:** "No external contributions, bug reports only." The distinction: public visibility and accepting contributions are separate decisions. Open-source AGPL license ≠ accepting PRs. Bug *reports* welcome, bug *fix* PRs not accepted until the contribution workflow is documented and tested. The file at `KB/10-Projects/AgentAuth/decisions/014-no-external-contributions.md` has explicit exit criteria (test plan + merge plan + contribution guide + tested with one non-maintainer) so future-you knows when to supersede it. + +**User corrections (golden — blog material):** +1. **"Contributor" scope was wrong initially.** Agent wrote Decision 014 framing as "bug fixes allowed, feature PRs not." User corrected: no bug fix PRs either — bug *reports* only. Every PR needs review/test/merge work. There's no such thing as a low-effort PR review. "Bug fix PRs" sounds safe but still needs the workflow. +2. **Reading places without permission.** Earlier I read the agentauth-python README when user asked about docs in agentauth-core. User called it out: "why are you reading places i did not give you access to read this session." Valid. Should have asked before reaching into another repo. +3. **Heavy-handed rules vs explained reasoning.** When writing the first skill draft I had 9 MUSTs and multiple "Never skip this step" phrases. Skill-creator audit + user pushback showed: rules that explain *why* they exist are more durable than rules enforced with threats. The "Why these rules exist" framing actually includes the historical incidents that motivated each rule. + +**Memory is not append-only (session lesson):** Earlier memory tracked two "unlogged branches" (`fix/app-launch-tokens-endpoint` and `fix/docs-overhaul`) as pending FLOW.md entries. Both branches had been merged weeks ago. The memory entry stayed. Every session that loaded memory saw the stale reference and wasted attention confirming the branches were actually merged. **Rule: when merging a branch referenced in memory, update/delete the memory entry in the same session.** New feedback memory captures this: `~/.claude/projects/.../memory/feedback_clean_memory_before_merge.md`. + +**The python agent didn't follow the skill.** A separate Claude session working in agentauth-python wrote a per-repo "Decision 001: rebrand" file AND created Decision 013 at parent level AND created an empty `agentauth-python-sdk/decisions/` KB folder — none of which matched what the skill would have done. The skill existed but that session didn't invoke it. Root causes: (1) rebrand was misclassified as an ADR when it's clearly a marketing decision, (2) skill wasn't invoked at all — possibly because the work predated the restructure we did tonight, but also because the session was creating decision files without consulting any capture skill. **The fix is the classification principle + the skill's default-to-Decision behavior + better trigger phrasing in the skill description.** + +**Branch cleanup — 15 branches deleted:** Session ended with a full repo audit. Found 7 B0-B6 migration cherry-pick branches still existing months after merge, plus a `develop-harness-backup` (autonomous coding harness work already cherry-picked), a `devin/1775212397-add-wiki-pages` branch (unsolicited Devin PR that duplicated docs already in the repo and did a bad job), the merged `docs/readme-sdk-demo` branch, `whitesource/configure` auto-scanner branch, and two already-merged `fix/app-launch-tokens-endpoint` / `fix/docs-overhaul`. All gone. Repo now has exactly `develop` + `main` locally and remotely. + +**Root cleanup:** Deleted 7 stray scratch files from root and `docs/` that had accumulated from mid-April "scratch pad" sessions — `DEVELOPMENT_STANDARDS.md`, `MiniMaxPythonSDK_REVIEW.md`, `SDK_BLUEPRINT.md`, `GeminiReview/` folder, `docs/python-sdk-design{,-v2,-final}.md` (three versions of the same SDK design doc that ended up in the broker repo by mistake). + +**Merged `docs/readme-sdk-demo` to develop** as `511dde6`. The branch carried the CONTRIBUTING rewrite (which is now inconsistent with Decision 014 — needs follow-up update on develop), the README SDK section (questionable value, user was skeptical earlier), the ADR structure, SECURITY fixes, and the root cleanup. + +**What's NOT done (handoff to next session):** +- CONTRIBUTING.md update per Decision 014 (no external contributions) — current version still encourages PRs, inconsistent with new policy +- README SDK section — user was questioning its value; may need to remove or rework +- Domain placeholder emails in CLA.md, ENTERPRISE_LICENSE.md, SECURITY.md, CODE_OF_CONDUCT.md — per Decision 013 domain is `agentwrit.com` +- `docs/api/openapi.yaml` still says Apache 2.0 +- `docs/getting-started-developer.md` needs SDK link + +--- + ### Public release readiness session (2026-04-08) **What happened:** Implemented the “public release readiness” plan: created `.plans/release-readiness.md` (merge checklist, license tradeoffs Apache vs source-available), `.plans/reviews/public-release-review-2026-04-08.md` (structured review snapshot), updated `AGENTS.md` / `FLOW.md`, fixed `CONTRIBUTING.md` (wrong clone URL, wrong import path, obsolete `smoketest` in tree), fixed `SECURITY.md` (stale limitations + broken KNOWN-ISSUES link), added `CODE_OF_CONDUCT.md`. diff --git a/TECH-DEBT.md b/TECH-DEBT.md index 4249276..6df1de0 100644 --- a/TECH-DEBT.md +++ b/TECH-DEBT.md @@ -249,6 +249,50 @@ The repo has accumulated artifacts from migration, multiple agent sessions, and --- +## TD-VUL-005/006 — GHAS-gated workflows disabled (M-sec, 2026-04-10) + +Three GitHub security features require GitHub Advanced Security (GHAS) +on private repos. `devonartis/agentauth` is currently private without +GHAS, so all three fail on first run. All three become FREE when the +repo flips public (Phase 4 of release strategy). + +| ID | Workflow / Feature | What it gives | Status | +|----|-------------------|---------------|--------| +| TD-VUL-005 | `dep-review` job in `ci.yml` | Dependency graph + license policy scanning on every PR | Job commented out | +| TD-VUL-006a | `codeql.yml` (Go SAST) | Static analysis findings in Security tab, weekly scan | Workflow trigger changed to `workflow_dispatch` only | +| TD-VUL-006b | `scorecard.yml` (OpenSSF Scorecard) | Supply-chain posture score (badge on README) | Workflow trigger changed to `workflow_dispatch` only | + +Remaining security coverage while these are disabled: + - `govulncheck` — stdlib + Go module CVEs (live, blocking) + - `gosec` — application-layer static analysis (live, blocking) + - `contamination` grep — enterprise-module references (live, blocking) + +**Fix sequence** when the repo flips public (no GHAS purchase needed): + 1. `dep-review`: uncomment the job block in `.github/workflows/ci.yml` + and restore it to the `gates-passed` needs list if branch protection + requires it. + 2. `codeql.yml`: revert the `on:` block header to `pull_request` + + `push` + `schedule` (see original block preserved in the comment). + 3. `scorecard.yml`: same — restore the original `on:` block. + 4. Add badges to `README.md` (Task 30 in the M-sec plan) — CodeQL + badge and Scorecard badge URLs are already in the plan draft. + +--- + +## Go stdlib vulnerabilities (M-sec CI baseline, 2026-04-10) — RESOLVED + +`govulncheck ./...` run on the M-sec baseline surfaced 4 stdlib CVEs, all +fixable by bumping the `toolchain` directive in `go.mod` from `go1.25.7` +to `go1.25.9`. **Resolved 2026-04-10 in Task 23** — toolchain bumped, +`govulncheck ./...` now returns "No vulnerabilities found." + +| ID | Advisory | Package | Fixed in | Status | +|----|----------|---------|----------|--------| +| TD-VUL-001 | GO-2026-4947 | `crypto/x509` | `go1.25.9` | RESOLVED | +| TD-VUL-002 | GO-2026-4946 | `crypto/x509` | `go1.25.9` | RESOLVED | +| TD-VUL-003 | GO-2026-4870 (TLS 1.3 KeyUpdate DoS) | `crypto/tls` | `go1.25.9` | RESOLVED | +| TD-VUL-004 | GO-2026-4601 (IPv6 host literal parsing) | `net/url` | `go1.25.8` | RESOLVED | + ## When to Fix Documentation and script drift items (TD-D*, TD-S*) should be resolved **after all cherry-pick batches are complete** (B0-B6). Doing them now risks conflicts with incoming commits. Schedule as a dedicated docs refresh phase post-migration. diff --git a/cmd/aactl/client.go b/cmd/aactl/client.go index caecf0c..d72c72b 100644 --- a/cmd/aactl/client.go +++ b/cmd/aactl/client.go @@ -47,9 +47,12 @@ func (c *client) authenticate() error { if c.token != "" { return nil } - body, _ := json.Marshal(map[string]string{ + body, err := json.Marshal(map[string]string{ "secret": c.secret, }) + if err != nil { + return fmt.Errorf("marshal auth request: %w", err) + } resp, err := c.http.Post(c.baseURL+"/v1/admin/auth", "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("auth request failed: %w", err) @@ -57,7 +60,10 @@ func (c *client) authenticate() error { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) + b, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("auth failed (HTTP %d): ", resp.StatusCode, readErr) + } return fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(b)) } @@ -144,7 +150,10 @@ func (c *client) doPostWithToken(path, bearerToken string) (int, []byte, error) return 0, nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("read response body: %w", err) + } return resp.StatusCode, b, nil } diff --git a/cmd/broker/main.go b/cmd/broker/main.go index 01160dd..29efc76 100644 --- a/cmd/broker/main.go +++ b/cmd/broker/main.go @@ -40,7 +40,6 @@ import ( "time" "github.com/devonartis/agentauth/internal/admin" - "github.com/devonartis/agentauth/internal/keystore" "github.com/devonartis/agentauth/internal/app" "github.com/devonartis/agentauth/internal/audit" "github.com/devonartis/agentauth/internal/authz" @@ -48,6 +47,7 @@ import ( "github.com/devonartis/agentauth/internal/deleg" "github.com/devonartis/agentauth/internal/handler" "github.com/devonartis/agentauth/internal/identity" + "github.com/devonartis/agentauth/internal/keystore" "github.com/devonartis/agentauth/internal/obs" "github.com/devonartis/agentauth/internal/problemdetails" "github.com/devonartis/agentauth/internal/revoke" diff --git a/go.mod b/go.mod index afcc682..5edde8b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/devonartis/agentauth go 1.24.0 -toolchain go1.25.7 +toolchain go1.25.9 require ( github.com/prometheus/client_golang v1.23.2 diff --git a/internal/admin/admin_hdl.go b/internal/admin/admin_hdl.go index 91f27c1..726ce34 100644 --- a/internal/admin/admin_hdl.go +++ b/internal/admin/admin_hdl.go @@ -206,4 +206,3 @@ func (h *AdminHdl) handleCreateLaunchToken(w http.ResponseWriter, r *http.Reques obs.Warn(mod, hdlCmp, "failed to encode launch token response", "err="+err.Error()) } } - diff --git a/internal/admin/admin_hdl_test.go b/internal/admin/admin_hdl_test.go index ec0a87d..8ace692 100644 --- a/internal/admin/admin_hdl_test.go +++ b/internal/admin/admin_hdl_test.go @@ -525,4 +525,3 @@ func TestCreateLaunchToken_AppCallerAuditOnCeilingExceeded(t *testing.T) { t.Fatal("expected audit event EventScopeCeilingExceeded") } } - diff --git a/internal/admin/admin_svc.go b/internal/admin/admin_svc.go index 36a0964..d377055 100644 --- a/internal/admin/admin_svc.go +++ b/internal/admin/admin_svc.go @@ -46,9 +46,9 @@ var adminScope = []string{ // Sentinel errors returned by admin operations. var ( - ErrInvalidSecret = errors.New("invalid client secret") + ErrInvalidSecret = errors.New("invalid client secret") ErrAgentNameEmpty = errors.New("agent_name is required") - ErrScopeEmpty = errors.New("allowed_scope must not be empty") + ErrScopeEmpty = errors.New("allowed_scope must not be empty") ) // CreateLaunchTokenReq is the JSON request body for @@ -252,4 +252,3 @@ func (s *AdminSvc) ConsumeLaunchToken(tokenStr string) error { } return s.store.ConsumeLaunchToken(tokenStr) } - diff --git a/internal/admin/admin_svc_test.go b/internal/admin/admin_svc_test.go index 8976ff0..a46a2f4 100644 --- a/internal/admin/admin_svc_test.go +++ b/internal/admin/admin_svc_test.go @@ -114,7 +114,7 @@ func TestAuthenticate_DifferentLengthSecret(t *testing.T) { t.Errorf("expected ErrInvalidSecret for different-length secret, got: %v", err) } - _, err = svc.Authenticate(testSecret+"extra-long-suffix-that-should-fail") + _, err = svc.Authenticate(testSecret + "extra-long-suffix-that-should-fail") if err != ErrInvalidSecret { t.Errorf("expected ErrInvalidSecret for longer secret, got: %v", err) } @@ -390,7 +390,10 @@ func TestCreateLaunchToken_HexFormat(t *testing.T) { } } -// Compile-time check: LaunchTokenRecord fields match spec. +// Compile-time check: LaunchTokenRecord fields match spec. The purpose of +// this test is that every field in the literal below MUST exist on the type. +// If a field is renamed or removed upstream, this test fails to compile — +// which is the point. Do not "simplify" by removing fields. func TestLaunchTokenRecord_SpecCompliance(t *testing.T) { rec := store.LaunchTokenRecord{ Token: "abc", @@ -402,6 +405,9 @@ func TestLaunchTokenRecord_SpecCompliance(t *testing.T) { ExpiresAt: time.Now(), CreatedBy: adminSub, } + // Silence govet unusedwrite: the writes above are *intentional* — the + // literal is exhaustive on purpose to lock the struct's field set. + _ = rec // ConsumedAt is a pointer — nil means not consumed. if rec.ConsumedAt != nil { t.Error("new record should have nil ConsumedAt") diff --git a/internal/app/app_hdl.go b/internal/app/app_hdl.go index f0116f7..7ab5d11 100644 --- a/internal/app/app_hdl.go +++ b/internal/app/app_hdl.go @@ -19,8 +19,8 @@ import ( ) const ( - hdlMod = "app" - hdlCmp = "handler" + hdlMod = "app" + hdlCmp = "handler" maxBodyBytes = int64(1 << 20) // 1 MB ) diff --git a/internal/app/app_svc_test.go b/internal/app/app_svc_test.go index 25050fc..bf2eb46 100644 --- a/internal/app/app_svc_test.go +++ b/internal/app/app_svc_test.go @@ -73,12 +73,12 @@ func TestRegisterApp_InvalidName(t *testing.T) { svc := newTestAppSvc(t) cases := []string{ - "", // empty - "My App", // spaces - "my_app", // underscores - "-my-app", // starts with hyphen - "my--app", // consecutive hyphens - "1myapp", // starts with digit + "", // empty + "My App", // spaces + "my_app", // underscores + "-my-app", // starts with hyphen + "my--app", // consecutive hyphens + "1myapp", // starts with digit } for _, name := range cases { if _, err := svc.RegisterApp(name, []string{"read:data:*"}, "admin", 0); err == nil { diff --git a/internal/audit/audit_log.go b/internal/audit/audit_log.go index 7944dcd..1aaa58b 100644 --- a/internal/audit/audit_log.go +++ b/internal/audit/audit_log.go @@ -28,19 +28,19 @@ import ( // events that an auditor or SIEM would query. Auth failures and scope // violations are the ones that matter most for incident response. const ( - EventAdminAuth = "admin_auth" - EventAdminAuthFailed = "admin_auth_failed" - EventLaunchTokenIssued = "launch_token_issued" - EventLaunchTokenDenied = "launch_token_denied" - EventAgentRegistered = "agent_registered" - EventRegistrationViolation = "registration_policy_violation" - EventTokenIssued = "token_issued" - EventTokenRevoked = "token_revoked" - EventTokenRenewed = "token_renewed" - EventTokenReleased = "token_released" - EventTokenRenewalFailed = "token_renewal_failed" - EventDelegationCreated = "delegation_created" - EventResourceAccessed = "resource_accessed" + EventAdminAuth = "admin_auth" + EventAdminAuthFailed = "admin_auth_failed" + EventLaunchTokenIssued = "launch_token_issued" + EventLaunchTokenDenied = "launch_token_denied" + EventAgentRegistered = "agent_registered" + EventRegistrationViolation = "registration_policy_violation" + EventTokenIssued = "token_issued" + EventTokenRevoked = "token_revoked" + EventTokenRenewed = "token_renewed" + EventTokenReleased = "token_released" + EventTokenRenewalFailed = "token_renewal_failed" + EventDelegationCreated = "delegation_created" + EventResourceAccessed = "resource_accessed" EventTokenAuthFailed = "token_auth_failed" EventTokenRevokedAccess = "token_revoked_access" EventScopeViolation = "scope_violation" @@ -48,24 +48,24 @@ const ( EventDelegationAttenuationViolation = "delegation_attenuation_violation" EventScopesCeilingUpdated = "scopes_ceiling_updated" - EventAppRegistered = "app_registered" + EventAppRegistered = "app_registered" EventAppAuthenticated = "app_authenticated" - EventAppAuthFailed = "app_auth_failed" - EventAppUpdated = "app_updated" - EventAppDeregistered = "app_deregistered" - EventAppRateLimited = "app_rate_limited" + EventAppAuthFailed = "app_auth_failed" + EventAppUpdated = "app_updated" + EventAppDeregistered = "app_deregistered" + EventAppRateLimited = "app_rate_limited" ) // AuditEvent is a single immutable entry in the audit trail. The Hash // field chains to PrevHash of the subsequent event, creating a // tamper-evident sequence. type AuditEvent struct { - ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` - EventType string `json:"event_type"` - AgentID string `json:"agent_id,omitempty"` - TaskID string `json:"task_id,omitempty"` - OrchID string `json:"orch_id,omitempty"` + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + EventType string `json:"event_type"` + AgentID string `json:"agent_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + OrchID string `json:"orch_id,omitempty"` Detail string `json:"detail"` Resource string `json:"resource,omitempty"` Outcome string `json:"outcome,omitempty"` diff --git a/internal/authz/val_mw.go b/internal/authz/val_mw.go index 64a5b8c..30abe01 100644 --- a/internal/authz/val_mw.go +++ b/internal/authz/val_mw.go @@ -71,7 +71,7 @@ func (m *ValMw) Wrap(next http.Handler) http.Handler { if authHeader == "" { if m.auditLog != nil { m.auditLog.Record(audit.EventTokenAuthFailed, "", "", "", "missing authorization header | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 401, "unauthorized", "missing authorization header", r.URL.Path) return @@ -80,7 +80,7 @@ func (m *ValMw) Wrap(next http.Handler) http.Handler { if !strings.HasPrefix(authHeader, "Bearer ") { if m.auditLog != nil { m.auditLog.Record(audit.EventTokenAuthFailed, "", "", "", "invalid authorization scheme | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 401, "unauthorized", "invalid authorization scheme", r.URL.Path) return @@ -91,7 +91,7 @@ func (m *ValMw) Wrap(next http.Handler) http.Handler { if err != nil { if m.auditLog != nil { m.auditLog.Record(audit.EventTokenAuthFailed, "", "", "", "token verification failed: "+err.Error()+" | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 401, "unauthorized", "token verification failed", r.URL.Path) return @@ -100,7 +100,7 @@ func (m *ValMw) Wrap(next http.Handler) http.Handler { if m.revSvc != nil && m.revSvc.IsRevoked(claims) { if m.auditLog != nil { m.auditLog.Record(audit.EventTokenRevokedAccess, claims.Sub, claims.TaskId, claims.OrchId, "revoked token used | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 403, "insufficient_scope", "token has been revoked", r.URL.Path) return @@ -111,7 +111,7 @@ func (m *ValMw) Wrap(next http.Handler) http.Handler { if m.auditLog != nil { m.auditLog.Record(audit.EventTokenAuthFailed, claims.Sub, claims.TaskId, claims.OrchId, "audience mismatch | expected="+m.audience+" | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 401, "unauthorized", "token audience mismatch", r.URL.Path) return @@ -139,7 +139,7 @@ func (m *ValMw) RequireScope(scope string, next http.Handler) http.Handler { if m.auditLog != nil { m.auditLog.Record(audit.EventScopeViolation, claims.Sub, claims.TaskId, claims.OrchId, "scope_violation | required="+scope+" | actual="+strings.Join(claims.Scope, ",")+" | path="+r.URL.Path, - audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) + audit.WithOutcome("denied"), audit.WithResource(r.URL.Path)) } problemdetails.WriteProblem(r.Context(), w, 403, "insufficient_scope", "token lacks required scope: "+scope, r.URL.Path) return diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index d240527..a58a2f8 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -42,20 +42,20 @@ const AdminBcryptCost = 12 // Cfg holds the complete broker configuration derived from environment // variables. Use [Load] to create an instance with defaults applied. type Cfg struct { - Port string // AA_PORT (default "8080") - BindAddress string // AA_BIND_ADDRESS (default "127.0.0.1") - LogLevel string // AA_LOG_LEVEL (default "verbose") - TrustDomain string // AA_TRUST_DOMAIN (default "agentauth.local") - DefaultTTL int // AA_DEFAULT_TTL (default 300 seconds) - AppTokenTTL int // AA_APP_TOKEN_TTL (default 1800 seconds / 30 min) - AdminSecret string // AA_ADMIN_SECRET (required for admin auth) - SeedTokens bool // AA_SEED_TOKENS (dev only, default false) - DBPath string // AA_DB_PATH (default "./agentauth.db") - SigningKeyPath string // AA_SIGNING_KEY_PATH (default "./signing.key") - TLSMode string // AA_TLS_MODE: none|tls|mtls (default "none") - TLSCert string // AA_TLS_CERT: path to TLS certificate PEM file - TLSKey string // AA_TLS_KEY: path to TLS private key PEM file - TLSClientCA string // AA_TLS_CLIENT_CA: path to client CA PEM file (mtls only) + Port string // AA_PORT (default "8080") + BindAddress string // AA_BIND_ADDRESS (default "127.0.0.1") + LogLevel string // AA_LOG_LEVEL (default "verbose") + TrustDomain string // AA_TRUST_DOMAIN (default "agentauth.local") + DefaultTTL int // AA_DEFAULT_TTL (default 300 seconds) + AppTokenTTL int // AA_APP_TOKEN_TTL (default 1800 seconds / 30 min) + AdminSecret string // AA_ADMIN_SECRET (required for admin auth) + SeedTokens bool // AA_SEED_TOKENS (dev only, default false) + DBPath string // AA_DB_PATH (default "./agentauth.db") + SigningKeyPath string // AA_SIGNING_KEY_PATH (default "./signing.key") + TLSMode string // AA_TLS_MODE: none|tls|mtls (default "none") + TLSCert string // AA_TLS_CERT: path to TLS certificate PEM file + TLSKey string // AA_TLS_KEY: path to TLS private key PEM file + TLSClientCA string // AA_TLS_CLIENT_CA: path to client CA PEM file (mtls only) Audience string // AA_AUDIENCE: expected token audience (default "agentauth", empty = skip) Mode string // MODE: development|production (default "development") AdminSecretHash string // bcrypt hash of admin secret (derived at load time) @@ -71,22 +71,22 @@ func Load() (Cfg, error) { cfgMode, cfgSecret, cfgPath := loadConfigFile() c := Cfg{ - Port: envOr("AA_PORT", "8080"), - BindAddress: envOr("AA_BIND_ADDRESS", "127.0.0.1"), - LogLevel: envOr("AA_LOG_LEVEL", "verbose"), - TrustDomain: envOr("AA_TRUST_DOMAIN", "agentauth.local"), - DefaultTTL: envIntOr("AA_DEFAULT_TTL", 300), - AppTokenTTL: envIntOr("AA_APP_TOKEN_TTL", 1800), - AdminSecret: os.Getenv("AA_ADMIN_SECRET"), - SeedTokens: envOr("AA_SEED_TOKENS", "false") == "true", + Port: envOr("AA_PORT", "8080"), + BindAddress: envOr("AA_BIND_ADDRESS", "127.0.0.1"), + LogLevel: envOr("AA_LOG_LEVEL", "verbose"), + TrustDomain: envOr("AA_TRUST_DOMAIN", "agentauth.local"), + DefaultTTL: envIntOr("AA_DEFAULT_TTL", 300), + AppTokenTTL: envIntOr("AA_APP_TOKEN_TTL", 1800), + AdminSecret: os.Getenv("AA_ADMIN_SECRET"), + SeedTokens: envOr("AA_SEED_TOKENS", "false") == "true", DBPath: envOr("AA_DB_PATH", "./agentauth.db"), SigningKeyPath: envOr("AA_SIGNING_KEY_PATH", "./signing.key"), - TLSMode: envOr("AA_TLS_MODE", "none"), - TLSCert: os.Getenv("AA_TLS_CERT"), - TLSKey: os.Getenv("AA_TLS_KEY"), - TLSClientCA: os.Getenv("AA_TLS_CLIENT_CA"), - ConfigPath: cfgPath, - Mode: "development", + TLSMode: envOr("AA_TLS_MODE", "none"), + TLSCert: os.Getenv("AA_TLS_CERT"), + TLSKey: os.Getenv("AA_TLS_KEY"), + TLSClientCA: os.Getenv("AA_TLS_CLIENT_CA"), + ConfigPath: cfgPath, + Mode: "development", } // AA_AUDIENCE: LookupEnv distinguishes unset (→ default "agentauth") // from explicitly empty (→ skip validation). diff --git a/internal/cfg/configfile_test.go b/internal/cfg/configfile_test.go index d7fe70f..65c8280 100644 --- a/internal/cfg/configfile_test.go +++ b/internal/cfg/configfile_test.go @@ -306,11 +306,11 @@ func TestIsBcryptHash_ValidHashes(t *testing.T) { func TestIsBcryptHash_InvalidHashes(t *testing.T) { invalid := []string{ - "$2a$", // prefix only - "$2a$12$short", // too short - "plaintext-secret", // no prefix - "", // empty - "$2a$12$" + strings.Repeat("a", 100), // too long + "$2a$", // prefix only + "$2a$12$short", // too short + "plaintext-secret", // no prefix + "", // empty + "$2a$12$" + strings.Repeat("a", 100), // too long } for _, h := range invalid { if isBcryptHash(h) { diff --git a/internal/deleg/deleg_svc.go b/internal/deleg/deleg_svc.go index d29e31e..b164ad9 100644 --- a/internal/deleg/deleg_svc.go +++ b/internal/deleg/deleg_svc.go @@ -51,9 +51,9 @@ type DelegReq struct { // the newly issued token, its TTL, and the complete delegation chain // including the new entry. type DelegResp struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - DelegationChain []token.DelegRecord `json:"delegation_chain"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + DelegationChain []token.DelegRecord `json:"delegation_chain"` } // DelegSvc is the delegation service. It verifies scope attenuation, @@ -111,7 +111,7 @@ func (s *DelegSvc) Delegate(delegatorClaims *token.TknClaims, req DelegReq) (*De delegatorClaims.Sub, delegatorClaims.TaskId, delegatorClaims.OrchId, fmt.Sprintf("delegation_attenuation_violation | delegator=%s | target=%s | requested=%v | allowed=%v", delegatorClaims.Sub, req.DelegateTo, req.Scope, delegatorClaims.Scope), - audit.WithOutcome("denied"), audit.WithDelegDepth(currentDepth)) + audit.WithOutcome("denied"), audit.WithDelegDepth(currentDepth)) } return nil, ErrScopeViolation } diff --git a/internal/handler/doc.go b/internal/handler/doc.go index f493da8..827804c 100644 --- a/internal/handler/doc.go +++ b/internal/handler/doc.go @@ -9,19 +9,19 @@ // // Endpoints by audience: // -// Public (no auth): -// - ChallengeHdl: GET /v1/challenge — nonce for agent registration -// - RegHdl: POST /v1/register — agent gets first credential -// - ValHdl: POST /v1/token/validate — apps verify agent tokens -// - HealthHdl: GET /v1/health — liveness + readiness -// - MetricsHdl: GET /v1/metrics — Prometheus scrape +// Public (no auth): +// - ChallengeHdl: GET /v1/challenge — nonce for agent registration +// - RegHdl: POST /v1/register — agent gets first credential +// - ValHdl: POST /v1/token/validate — apps verify agent tokens +// - HealthHdl: GET /v1/health — liveness + readiness +// - MetricsHdl: GET /v1/metrics — Prometheus scrape // -// Agent (Bearer auth): -// - RenewHdl: POST /v1/token/renew — extend session -// - ReleaseHdl: POST /v1/token/release — self-revoke when done -// - DelegHdl: POST /v1/delegate — create sub-token for another agent +// Agent (Bearer auth): +// - RenewHdl: POST /v1/token/renew — extend session +// - ReleaseHdl: POST /v1/token/release — self-revoke when done +// - DelegHdl: POST /v1/delegate — create sub-token for another agent // -// Admin (Bearer + admin:* scope): -// - RevokeHdl: POST /v1/revoke — kill switch (4 levels) -// - AuditHdl: GET /v1/audit/events — query tamper-evident trail +// Admin (Bearer + admin:* scope): +// - RevokeHdl: POST /v1/revoke — kill switch (4 levels) +// - AuditHdl: GET /v1/audit/events — query tamper-evident trail package handler diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index fa67ca0..81a8dfb 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -1361,7 +1361,7 @@ func TestRenew_DirectErrorMessageIsGeneric(t *testing.T) { // forcing tknSvc.Renew() to fail and exercising RenewHdl's error branch. type failingRevoker struct{} -func (failingRevoker) RevokeByJTI(_ string) error { return errors.New("simulated store failure") } +func (failingRevoker) RevokeByJTI(_ string) error { return errors.New("simulated store failure") } func (failingRevoker) IsRevoked(_ *token.TknClaims) bool { return false } // --- SEC-L2b H1/H7: Security headers present on all responses --- diff --git a/internal/handler/logging.go b/internal/handler/logging.go index 87a4860..ee9e362 100644 --- a/internal/handler/logging.go +++ b/internal/handler/logging.go @@ -38,14 +38,14 @@ func (rw *responseWriter) Write(b []byte) (int, error) { func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - + rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} - + next.ServeHTTP(rw, r) - + latency := time.Since(start) id := problemdetails.GetRequestID(r.Context()) - + obs.Ok("HTTP", "handler", "request completed", fmt.Sprintf("method=%s", r.Method), fmt.Sprintf("path=%s", r.URL.Path), diff --git a/internal/handler/logging_test.go b/internal/handler/logging_test.go index 3114e66..1df3f99 100644 --- a/internal/handler/logging_test.go +++ b/internal/handler/logging_test.go @@ -10,7 +10,7 @@ import ( func TestLoggingMiddleware(t *testing.T) { // Red Phase: This test will fail because the middleware is not yet implemented. - + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte("created")) //nolint:errcheck // test handler diff --git a/internal/handler/renew_hdl.go b/internal/handler/renew_hdl.go index d458e32..9b05a4d 100644 --- a/internal/handler/renew_hdl.go +++ b/internal/handler/renew_hdl.go @@ -49,7 +49,7 @@ func (h *RenewHdl) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.auditLog != nil && claims != nil { h.auditLog.Record(audit.EventTokenRenewalFailed, claims.Sub, claims.TaskId, claims.OrchId, fmt.Sprintf("token renewal failed for agent=%s: %s", claims.Sub, err.Error()), - audit.WithOutcome("denied")) + audit.WithOutcome("denied")) } obs.Warn("RENEW", "hdl", "token renewal failed", "err="+err.Error()) problemdetails.WriteProblem(r.Context(), w, http.StatusUnauthorized, "unauthorized", "token renewal failed", r.URL.Path) diff --git a/internal/handler/request_id_test.go b/internal/handler/request_id_test.go index d26c023..0a91ba0 100644 --- a/internal/handler/request_id_test.go +++ b/internal/handler/request_id_test.go @@ -10,7 +10,7 @@ import ( func TestRequestIDMiddleware(t *testing.T) { // Red Phase: This test will fail because the middleware is not yet implemented. - + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := problemdetails.GetRequestID(r.Context()) if id == "" { diff --git a/internal/handler/security_hdl_test.go b/internal/handler/security_hdl_test.go index ed80f9c..c47dc54 100644 --- a/internal/handler/security_hdl_test.go +++ b/internal/handler/security_hdl_test.go @@ -6,7 +6,6 @@ import ( "testing" ) - func TestSecurityHeaders_BaseHeaders(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/handler/val_hdl.go b/internal/handler/val_hdl.go index acd6fb1..82a7941 100644 --- a/internal/handler/val_hdl.go +++ b/internal/handler/val_hdl.go @@ -32,7 +32,7 @@ type validateReq struct { } type validateRespValid struct { - Valid bool `json:"valid"` + Valid bool `json:"valid"` Claims *token.TknClaims `json:"claims"` } diff --git a/internal/identity/id_svc.go b/internal/identity/id_svc.go index 67cbd21..863b2c4 100644 --- a/internal/identity/id_svc.go +++ b/internal/identity/id_svc.go @@ -41,10 +41,10 @@ var ( // RegisterReq contains the fields submitted by an agent in the // POST /v1/register request body. All fields are required. type RegisterReq struct { - LaunchToken string `json:"launch_token"` - Nonce string `json:"nonce"` - PublicKey string `json:"public_key"` // base64-encoded Ed25519 public key - Signature string `json:"signature"` // base64-encoded Ed25519 signature of nonce + LaunchToken string `json:"launch_token"` + Nonce string `json:"nonce"` + PublicKey string `json:"public_key"` // base64-encoded Ed25519 public key + Signature string `json:"signature"` // base64-encoded Ed25519 signature of nonce // OrchID identifies the orchestrator that launched this agent. OrchID string `json:"orch_id"` // TaskID identifies the specific task this agent was created for. @@ -59,7 +59,7 @@ type RegisterReq struct { type RegisterResp struct { // AgentID is the SPIFFE URI assigned to the registered agent // (format: spiffe://{trustDomain}/agent/{orchID}/{taskID}/{instanceID}). - AgentID string `json:"agent_id"` + AgentID string `json:"agent_id"` AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } @@ -144,7 +144,7 @@ func (s *IdSvc) Register(req RegisterReq) (*RegisterResp, error) { if s.auditLog != nil { s.auditLog.Record("registration_policy_violation", "", req.TaskID, req.OrchID, fmt.Sprintf("scope violation: requested %v exceeds allowed %v", req.RequestedScope, ltRec.AllowedScope), - audit.WithOutcome("denied")) + audit.WithOutcome("denied")) } obs.RegistrationsTotal.WithLabelValues("failure").Inc() obs.Warn("IDENTITY", "Register", "scope violation", diff --git a/internal/keystore/keystore.go b/internal/keystore/keystore.go index e16fc6d..59f97d4 100644 --- a/internal/keystore/keystore.go +++ b/internal/keystore/keystore.go @@ -59,7 +59,14 @@ func parseKey(data []byte) (ed25519.PublicKey, ed25519.PrivateKey, error) { if !ok { return nil, nil, fmt.Errorf("keystore: key is %T, want ed25519.PrivateKey", key) } - return priv.Public().(ed25519.PublicKey), priv, nil + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + // Unreachable: ed25519.PrivateKey.Public() is documented to return + // ed25519.PublicKey. Defensive check satisfies errcheck and guards + // against stdlib contract changes. + return nil, nil, fmt.Errorf("keystore: public key is %T, want ed25519.PublicKey", priv.Public()) + } + return pub, priv, nil } func writeKey(path string, priv ed25519.PrivateKey) error { diff --git a/internal/mutauth/heartbeat.go b/internal/mutauth/heartbeat.go index 0a24bad..8a28878 100644 --- a/internal/mutauth/heartbeat.go +++ b/internal/mutauth/heartbeat.go @@ -83,7 +83,7 @@ func (h *HeartbeatMgr) CheckLiveness(agentID string) (alive bool, missedCount in // StartMonitor runs a background goroutine that periodically checks all tracked // agents for missed heartbeats. Agents exceeding maxMiss are auto-revoked when // revSvc is configured, otherwise they are logged as warnings for investigation. -// The goroutine exits when ctx is cancelled. +// The goroutine exits when ctx is canceled. func (h *HeartbeatMgr) StartMonitor(ctx context.Context, interval time.Duration) { if interval > 0 { h.interval = interval @@ -125,9 +125,17 @@ func (h *HeartbeatMgr) sweep() { if s.missedCount >= h.maxMiss { if h.revSvc != nil { - _, _ = h.revSvc.Revoke("agent", id) - obs.Warn("MUTAUTH", "Heartbeat.Sweep", "agent auto-revoked", - "agent_id="+id, "missed="+itoa(s.missedCount)) + // Best-effort revocation. Failure is logged via obs rather than + // returned because sweep() runs in a background goroutine with + // no caller to handle an error. The agent stays tracked so the + // next sweep retries. + if _, err := h.revSvc.Revoke("agent", id); err != nil { + obs.Warn("MUTAUTH", "Heartbeat.Sweep", "agent auto-revoke failed", + "agent_id="+id, "err="+err.Error()) + } else { + obs.Warn("MUTAUTH", "Heartbeat.Sweep", "agent auto-revoked", + "agent_id="+id, "missed="+itoa(s.missedCount)) + } } else { obs.Warn("MUTAUTH", "Heartbeat.Sweep", "agent flagged for investigation", "agent_id="+id, "missed="+itoa(s.missedCount)) diff --git a/internal/mutauth/mut_auth_hdl_test.go b/internal/mutauth/mut_auth_hdl_test.go index aa34ea2..5f24d74 100644 --- a/internal/mutauth/mut_auth_hdl_test.go +++ b/internal/mutauth/mut_auth_hdl_test.go @@ -43,7 +43,7 @@ func testSetup(t *testing.T) ( TaskID: "task-1", Scope: []string{"read:Data:*"}, RegisteredAt: time.Now().UTC(), - PublicKey: pubA, + PublicKey: pubA, }); err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func testSetup(t *testing.T) ( TaskID: "task-2", Scope: []string{"write:Data:*"}, RegisteredAt: time.Now().UTC(), - PublicKey: pubB, + PublicKey: pubB, }); err != nil { t.Fatal(err) } @@ -218,7 +218,7 @@ func TestHandshakePeerMismatch(t *testing.T) { TaskID: "task-3", Scope: []string{"read:Data:*"}, RegisteredAt: time.Now().UTC(), - PublicKey: pubC, + PublicKey: pubC, }); err != nil { t.Fatal(err) } diff --git a/internal/problemdetails/problemdetails.go b/internal/problemdetails/problemdetails.go index f76cc29..9c24d05 100644 --- a/internal/problemdetails/problemdetails.go +++ b/internal/problemdetails/problemdetails.go @@ -50,13 +50,13 @@ func WriteProblem(ctx context.Context, w http.ResponseWriter, status int, errTyp // errorCode and hint. func WriteProblemExtended(ctx context.Context, w http.ResponseWriter, status int, errType, detail, instance, errorCode, hint string) { requestID := GetRequestID(ctx) - + p := ProblemDetail{ Type: "urn:agentauth:error:" + errType, - Title: http.StatusText(status), - Status: status, - Detail: detail, - Instance: instance, + Title: http.StatusText(status), + Status: status, + Detail: detail, + Instance: instance, ErrorCode: errorCode, RequestID: requestID, Hint: hint, diff --git a/internal/store/sql_store.go b/internal/store/sql_store.go index 1c4f707..bd7b9c5 100644 --- a/internal/store/sql_store.go +++ b/internal/store/sql_store.go @@ -36,8 +36,8 @@ var ( ErrTokenNotFound = errors.New("launch token not found") ErrTokenExpired = errors.New("launch token expired") ErrTokenConsumed = errors.New("launch token already consumed") - ErrAgentNotFound = errors.New("agent not found") - ErrAppNotFound = errors.New("app not found") + ErrAgentNotFound = errors.New("agent not found") + ErrAppNotFound = errors.New("app not found") ) // LaunchTokenRecord represents a pre-authorized launch token created by an @@ -86,7 +86,7 @@ type AgentRecord struct { TaskID string // Scope is the set of permissions granted at registration, always a // subset of the launch token's AllowedScope. - Scope []string + Scope []string RegisteredAt time.Time LastSeen time.Time ExpiresAt time.Time @@ -569,6 +569,9 @@ func (s *SqlStore) QueryAuditEvents(filters audit.QueryFilters) ([]audit.AuditEv offset = 0 } + // #nosec G202 -- `where` is assembled from fixed-template fragments built + // above (see whereClauses); every user-supplied value is a `?` placeholder + // bound through queryArgs. No untrusted strings enter the SQL text. selectQ := "SELECT id, timestamp, event_type, agent_id, task_id, orch_id, detail, " + "resource, outcome, deleg_depth, deleg_chain_hash, bytes_transferred, " + "hash, prev_hash FROM audit_events" + @@ -630,13 +633,13 @@ func (s *SqlStore) QueryAuditEvents(filters audit.QueryFilters) ([]audit.AuditEv // launch tokens within their scope ceiling. The secret hash is never returned // in API responses. type AppRecord struct { - AppID string // "app-{name}-{random6hex}" - Name string // Human-readable, unique - ClientID string // "{abbrev}-{random12hex}" - ClientSecretHash string // bcrypt hash of the client secret (never returned) - ScopeCeiling []string // Scope ceiling; JSON-marshaled in DB - TokenTTL int // JWT TTL in seconds (default 1800) - Status string // "active" | "inactive" + AppID string // "app-{name}-{random6hex}" + Name string // Human-readable, unique + ClientID string // "{abbrev}-{random12hex}" + ClientSecretHash string // bcrypt hash of the client secret (never returned) + ScopeCeiling []string // Scope ceiling; JSON-marshaled in DB + TokenTTL int // JWT TTL in seconds (default 1800) + Status string // "active" | "inactive" CreatedAt time.Time UpdatedAt time.Time CreatedBy string diff --git a/internal/store/sql_store_test.go b/internal/store/sql_store_test.go index 6e9aff1..662687d 100644 --- a/internal/store/sql_store_test.go +++ b/internal/store/sql_store_test.go @@ -383,7 +383,6 @@ func TestSaveAgent_NoAppIDByDefault(t *testing.T) { } } - // --- SQLite audit persistence --- func TestInitDB_CreatesAuditTable(t *testing.T) { diff --git a/internal/token/tkn_svc_test.go b/internal/token/tkn_svc_test.go index 7d9f038..096c81b 100644 --- a/internal/token/tkn_svc_test.go +++ b/internal/token/tkn_svc_test.go @@ -390,7 +390,6 @@ func TestIssueWithoutSid_DefaultsEmpty(t *testing.T) { } } - func TestRenew_RevokesPredecessor(t *testing.T) { pub, priv := testKeyPair(t) svc := NewTknSvc(priv, pub, testCfg()) diff --git a/scripts/gates.sh b/scripts/gates.sh index 33ce2a7..926b62d 100755 --- a/scripts/gates.sh +++ b/scripts/gates.sh @@ -1,24 +1,68 @@ #!/usr/bin/env bash set -euo pipefail -# gates.sh — quality gate runner for AgentAuth -# Usage: ./scripts/gates.sh task (build + vet/lint + unit tests + security) -# ./scripts/gates.sh module (task gates + full test suite + Docker E2E) +# gates.sh — quality gate runner for AgentAuth (M-sec) +# +# Usage: +# ./scripts/gates.sh task Fast dev-loop gates (build/vet/lint/format/ +# contamination/short tests/security) +# ./scripts/gates.sh full Full CI-mirror gates (task + race tests + +# docker-build + smoke-l25 + sbom) +# ./scripts/gates.sh regression L4 full regression — iterate tests/*/regression.sh +# ./scripts/gates.sh --list-gates Print gate IDs one-per-line for parity test +# +# 'module' is retained as a deprecated alias for 'full'. +# +# Local/CI parity: this script's gate IDs must match ci.yml's GATE_LIST block. +# scripts/test-gate-parity.sh enforces this. MODE="${1:-}" + +# Authoritative gate list — single source of truth. +# scripts/test-gate-parity.sh reads this array; ci.yml's GATE_LIST comment +# block mirrors the same strings. If you add/remove/rename a gate, update BOTH. +GATES_TASK=( + build + vet + lint + format + contamination + unit-tests + gosec + govulncheck + go-mod-verify +) +GATES_FULL=( + "${GATES_TASK[@]}" + unit-tests-race + docker-build + smoke-l25 + sbom +) + +if [[ "$MODE" == "--list-gates" ]]; then + for g in "${GATES_FULL[@]}"; do echo "$g"; done + exit 0 +fi + if [[ -z "$MODE" ]]; then - echo "Usage: $0 {task|module|regression}" + echo "Usage: $0 {task|full|regression|--list-gates}" exit 1 fi -if [[ "$MODE" != "task" && "$MODE" != "module" && "$MODE" != "regression" ]]; then - echo "Error: unknown mode '$MODE'. Use 'task', 'module', or 'regression'." +# Alias: module -> full (deprecated) +if [[ "$MODE" == "module" ]]; then + echo "NOTE: 'module' is deprecated, use 'full'." >&2 + MODE="full" +fi + +if [[ "$MODE" != "task" && "$MODE" != "full" && "$MODE" != "regression" ]]; then + echo "Error: unknown mode '$MODE'. Use 'task', 'full', 'regression', or --list-gates." exit 1 fi PASS=0 FAIL=0 -WARN=0 SKIP=0 run_gate() { @@ -35,20 +79,6 @@ run_gate() { fi } -warn_gate() { - local name="$1" - shift - echo "" - echo "=== GATE: $name ===" - if "$@"; then - echo "--- PASS: $name ---" - PASS=$((PASS + 1)) - else - echo "--- WARN: $name (non-blocking) ---" - WARN=$((WARN + 1)) - fi -} - skip_gate() { local name="$1" local reason="$2" @@ -58,50 +88,89 @@ skip_gate() { SKIP=$((SKIP + 1)) } +# gosec exclusions — documented in .gosec.yml. Every excluded rule has a +# documented rationale there. Keep the flags here in sync with ci.yml's gosec +# job AND with the linters-settings.gosec.excludes block in .golangci.yml. +GOSEC_EXCLUDE="G117,G304,G101" + # --- TASK gates --- -run_gate "build" go build ./... +run_gate "build" go build ./cmd/broker ./cmd/aactl -# Lint: prefer golangci-lint, fall back to go vet +run_gate "vet" go vet ./... + +# Lint: require golangci-lint (no fallback — M-sec policy) if command -v golangci-lint &>/dev/null; then run_gate "lint" golangci-lint run ./... -elif go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest --version &>/dev/null 2>&1; then - run_gate "lint" go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run ./... else - run_gate "lint (vet fallback)" go vet ./... + echo "ERROR: golangci-lint not installed. Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + exit 1 fi -run_gate "unit tests" go test ./... -short -count=1 +# Format: gofmt -l must return empty +run_gate "format" bash -c 'test -z "$(gofmt -l .)"' + +# Contamination: zero enterprise refs in core +run_gate "contamination" bash -c "! grep -ri 'hitl\|approval\|oidc\|federation\|cloud\|sidecar' internal/ cmd/ 2>/dev/null" -# Security: gosec (advisory — warns but does not block) +run_gate "unit-tests" go test -short -count=1 ./... + +# Security: gosec (BLOCKING — flipped from warn per Decision 015) if command -v gosec &>/dev/null; then - warn_gate "security (gosec)" gosec -quiet ./... -elif go run github.com/securego/gosec/v2/cmd/gosec@latest -version &>/dev/null 2>&1; then - warn_gate "security (gosec)" go run github.com/securego/gosec/v2/cmd/gosec@latest -quiet ./... + run_gate "gosec" gosec -quiet -conf .gosec.yml -exclude="$GOSEC_EXCLUDE" -severity=medium ./... else - skip_gate "security (gosec)" "gosec not installed — skipping" + echo "ERROR: gosec not installed. Install: go install github.com/securego/gosec/v2/cmd/gosec@latest" + exit 1 fi -# --- MODULE gates (only if mode is module) --- +# Vulnerability check: govulncheck (BLOCKING) +if command -v govulncheck &>/dev/null; then + run_gate "govulncheck" govulncheck ./... +else + echo "ERROR: govulncheck not installed. Install: go install golang.org/x/vuln/cmd/govulncheck@latest" + exit 1 +fi -if [[ "$MODE" == "module" ]]; then - run_gate "full tests" go test ./... -count=1 +# Module integrity + tidy drift +run_gate "go-mod-verify" bash -c 'go mod verify && go mod tidy && git diff --exit-code go.mod go.sum' - # Live/E2E: start the broker and run HTTP smoke tests - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - if [[ -x "$SCRIPT_DIR/live_test.sh" ]]; then - run_gate "live tests (broker)" "$SCRIPT_DIR/live_test.sh" +# --- FULL gates (only if mode is full) --- + +if [[ "$MODE" == "full" ]]; then + run_gate "unit-tests-race" go test -race -count=1 -coverprofile=coverage.out ./... + + # Docker build: multi-stage image builds cleanly + if docker info >/dev/null 2>&1; then + run_gate "docker-build" docker build -t agentauth-ci:local . else - skip_gate "live tests (broker)" "scripts/live_test.sh not found or not executable" + skip_gate "docker-build" "Docker daemon not running" fi - # Docker live tests: deterministic gates — if Docker is available, these MUST pass. - if docker info >/dev/null 2>&1; then - if [[ -x "$SCRIPT_DIR/live_test_docker.sh" ]]; then - run_gate "live tests (broker docker)" "$SCRIPT_DIR/live_test_docker.sh" + # L2.5 smoke: core contract (issue/verify/revoke/deny) + # Honors BROKER_URL so an operator can point at an already-running + # broker on a non-default port (e.g. when 8080 is held by another + # project). Default matches scripts/stack_up.sh default mapping. + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + SMOKE_BROKER_URL="${BROKER_URL:-http://localhost:8080}" + if [[ -x "$SCRIPT_DIR/smoke/core-contract.sh" ]]; then + if docker info >/dev/null 2>&1; then + if curl -sf "$SMOKE_BROKER_URL/v1/health" >/dev/null 2>&1; then + run_gate "smoke-l25" env BROKER_URL="$SMOKE_BROKER_URL" "$SCRIPT_DIR/smoke/core-contract.sh" + else + skip_gate "smoke-l25" "broker not reachable at $SMOKE_BROKER_URL — run scripts/stack_up.sh first" + fi + else + skip_gate "smoke-l25" "Docker daemon not running" fi else - skip_gate "live tests (docker)" "Docker daemon not running — skipping Docker E2E gates" + skip_gate "smoke-l25" "scripts/smoke/core-contract.sh not found or not executable" + fi + + # SBOM: syft SPDX output. `syft scan` replaced `syft packages` in 1.x. + if command -v syft &>/dev/null; then + run_gate "sbom" syft scan dir:. -o spdx-json=sbom.spdx.json --quiet + else + skip_gate "sbom" "syft not installed — brew install syft or https://github.com/anchore/syft" fi fi @@ -118,7 +187,7 @@ if [[ "$MODE" == "regression" ]]; then if [ -f "$test_dir/regression.sh" ]; then runner="$test_dir/regression.sh" else - echo " SKIP $phase (no runner found)" + echo " SKIP $phase (no regression.sh runner)" continue fi echo " RUN $phase ($runner)" @@ -149,7 +218,6 @@ echo " GATE SUMMARY ($MODE mode)" echo "===============================" echo " PASS: $PASS" echo " FAIL: $FAIL" -echo " WARN: $WARN" echo " SKIP: $SKIP" echo "===============================" diff --git a/scripts/smoke/core-contract.sh b/scripts/smoke/core-contract.sh new file mode 100755 index 0000000..f5a11e3 --- /dev/null +++ b/scripts/smoke/core-contract.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +# core-contract.sh — L2.5 smoke test for agentauth-core +# +# Verifies the broker's core contract: +# 1. /v1/health returns 200 +# 2. Admin can authenticate (POST /v1/admin/auth) +# 3. Admin can create a launch token (POST /v1/admin/launch-tokens) +# 4. Agent can fetch a challenge nonce (GET /v1/challenge) +# 5. Agent can register via challenge-response (POST /v1/register) +# — Ed25519 key pair generated, nonce signed, public key presented +# — returns a short-lived Bearer access token +# 6. Agent token has correct JWT structure (alg=EdDSA, kid, exp > iat) +# 7. /v1/token/validate accepts the token (valid=true) +# 8. Admin can revoke the agent at /v1/revoke +# 9. /v1/token/validate rejects the revoked token (valid=false) +# 10. Registration with an out-of-scope requested_scope is denied +# +# Caller's responsibility: start the broker before running this script. +# This script does NOT start or stop the broker. Use scripts/stack_up.sh +# (Docker) or bin/broker (VPS mode) before calling. +# +# Required env: +# AA_ADMIN_SECRET (default: live-test-secret-32bytes-long-ok) +# BROKER_URL (default: http://localhost:8080) +# +# Dependencies: curl, jq, python3 with cryptography installed. +# python3 + cryptography is the established pattern for challenge-response +# in this repo — see tests/sec-l2b/integration.sh for prior art. + +BROKER_URL="${BROKER_URL:-http://localhost:8080}" +AA_ADMIN_SECRET="${AA_ADMIN_SECRET:-live-test-secret-32bytes-long-ok}" + +for dep in curl jq python3; do + if ! command -v $dep &>/dev/null; then + echo "FAIL: missing dependency: $dep" + exit 1 + fi +done +if ! python3 -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey' &>/dev/null; then + echo "FAIL: python3 cryptography package not installed (pip install cryptography)" + exit 1 +fi + +step=0 +pass() { step=$((step+1)); echo " [$step] PASS: $1"; } +fail() { step=$((step+1)); echo " [$step] FAIL: $1 — $2"; echo "L2.5 SMOKE: FAIL"; exit 1; } + +echo "=== L2.5 Core Contract Smoke ===" +echo "Broker: $BROKER_URL" + +# --- Step 1: Health check --- +HEALTH_STATUS=$(curl -so /dev/null -w "%{http_code}" "$BROKER_URL/v1/health" || true) +[[ "$HEALTH_STATUS" == "200" ]] || fail "health check" "expected 200, got $HEALTH_STATUS" +pass "health 200" + +# --- Step 2: Admin auth --- +ADMIN_RESP=$(curl -sf -X POST "$BROKER_URL/v1/admin/auth" \ + -H "Content-Type: application/json" \ + -d "{\"secret\":\"$AA_ADMIN_SECRET\"}" || echo "{}") +ADMIN_TOKEN=$(echo "$ADMIN_RESP" | jq -r '.access_token // empty') +[[ -n "$ADMIN_TOKEN" ]] || fail "admin auth" "no access_token in response: $ADMIN_RESP" +pass "admin authenticated" + +# --- Step 3: Create launch token --- +LT_RESP=$(curl -sf -X POST "$BROKER_URL/v1/admin/launch-tokens" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"agent_name":"smoke-l25-agent","allowed_scope":["read:data:*"],"max_ttl":300}' || echo "{}") +LAUNCH_TOKEN=$(echo "$LT_RESP" | jq -r '.launch_token // empty') +[[ -n "$LAUNCH_TOKEN" ]] || fail "launch token" "no launch_token in response: $LT_RESP" +pass "launch token issued" + +# --- Step 4: Challenge --- +CHALLENGE_RESP=$(curl -sf "$BROKER_URL/v1/challenge" || echo "{}") +NONCE=$(echo "$CHALLENGE_RESP" | jq -r '.nonce // empty') +[[ -n "$NONCE" ]] || fail "challenge" "no nonce in response: $CHALLENGE_RESP" +pass "challenge nonce fetched" + +# --- Step 5: Register agent (Ed25519 challenge-response) --- +# Python generates a keypair, signs the hex-decoded nonce, and POSTs to /v1/register. +REG_RESP=$(python3 </dev/null +} +HEADER=$(decode_jwt_part "$(echo "$AGENT_TOKEN" | cut -d'.' -f1)") +PAYLOAD=$(decode_jwt_part "$(echo "$AGENT_TOKEN" | cut -d'.' -f2)") +ALG=$(echo "$HEADER" | jq -r '.alg // empty') +KID=$(echo "$HEADER" | jq -r '.kid // empty') +EXP=$(echo "$PAYLOAD" | jq -r '.exp // 0') +IAT=$(echo "$PAYLOAD" | jq -r '.iat // 0') +JTI=$(echo "$PAYLOAD" | jq -r '.jti // empty') +[[ "$ALG" == "EdDSA" ]] || fail "jwt alg" "expected EdDSA, got $ALG" +[[ -n "$KID" ]] || fail "jwt kid" "kid missing" +[[ $EXP -gt $IAT ]] || fail "jwt exp" "exp ($EXP) must be > iat ($IAT)" +[[ -n "$JTI" ]] || fail "jwt jti" "jti missing" +pass "JWT structure valid (alg=EdDSA, kid, exp>iat, jti)" + +# --- Step 7: Token validate (accepted) --- +VAL_RESP=$(curl -sf -X POST "$BROKER_URL/v1/token/validate" \ + -H "Content-Type: application/json" \ + -d "{\"token\":\"$AGENT_TOKEN\"}" || echo "{}") +# Note: jq's `//` operator treats `false` as empty, so use plain .valid. +VAL_VALID=$(echo "$VAL_RESP" | jq -r '.valid') +[[ "$VAL_VALID" == "true" ]] || fail "token validate accepted" "expected valid=true, got: $VAL_RESP" +pass "token validate accepted" + +# --- Step 8: Revoke the agent --- +REV_STATUS=$(curl -so /tmp/rev_resp.json -w "%{http_code}" -X POST "$BROKER_URL/v1/revoke" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"level\":\"agent\",\"target\":\"$AGENT_ID\"}") +[[ "$REV_STATUS" == "200" ]] || fail "revocation" "expected 200, got $REV_STATUS (body: $(cat /tmp/rev_resp.json))" +pass "agent revoked ($AGENT_ID)" + +# --- Step 9: Validate revoked token is rejected --- +REV_VAL_RESP=$(curl -sf -X POST "$BROKER_URL/v1/token/validate" \ + -H "Content-Type: application/json" \ + -d "{\"token\":\"$AGENT_TOKEN\"}" || echo "{}") +REV_VAL_VALID=$(echo "$REV_VAL_RESP" | jq -r '.valid') +[[ "$REV_VAL_VALID" == "false" ]] || fail "revocation enforced" "expected valid=false after revoke, got: $REV_VAL_RESP" +pass "revoked token rejected (valid=false)" + +# --- Step 10: Out-of-scope registration is denied --- +# Fresh launch token with the SAME narrow ceiling, then try to register +# requesting a scope that's NOT in the ceiling. Broker must refuse. +LT2_RESP=$(curl -sf -X POST "$BROKER_URL/v1/admin/launch-tokens" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"agent_name":"smoke-oos-agent","allowed_scope":["read:data:*"],"max_ttl":300}') +LT2=$(echo "$LT2_RESP" | jq -r '.launch_token // empty') +[[ -n "$LT2" ]] || fail "oos setup" "could not mint second launch token" +NONCE2=$(curl -sf "$BROKER_URL/v1/challenge" | jq -r '.nonce // empty') +[[ -n "$NONCE2" ]] || fail "oos setup" "could not fetch second nonce" + +OOS_STATUS=$(python3 </dev/null; then + count=$(echo "$GATES_FROM_SCRIPT" | wc -l | tr -d ' ') + echo "PASS: gate lists match ($count gates)" + exit 0 +else + echo "FAIL: gates.sh and ci.yml disagree on the gate list" + echo "" + echo "--- gates.sh --list-gates ---" + echo "$GATES_FROM_SCRIPT" + echo "" + echo "--- ci.yml GATE_LIST block ---" + echo "$GATES_FROM_CI" + echo "" + echo "Diff (gates.sh vs ci.yml):" + diff <(echo "$GATES_FROM_SCRIPT") <(echo "$GATES_FROM_CI") || true + exit 1 +fi