diff --git a/.bootstrap/managed-files.json b/.bootstrap/managed-files.json new file mode 100644 index 0000000..efbb3a7 --- /dev/null +++ b/.bootstrap/managed-files.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "owner": "bootstrap", + "templateVersion": "2026.03.28.2", + "regenerationCommand": "bootstrap apply repo --manifest ./project.bootstrap.yaml", + "managedFiles": { + ".devcontainer/devcontainer.json": { + "sha256": "c8cd91bc1d2df220508bd84051723d480fe16d7bf8d93a8c3fa0beac403083ad", + "source": "bootstrap" + }, + ".githooks/pre-commit": { + "sha256": "992bc9b04d62f84df70ba1e42d7159a1d976190eae41597810aaafaa30252a4d", + "source": "bootstrap" + }, + ".github/PULL_REQUEST_TEMPLATE.md": { + "sha256": "b95b973bf0e6f5cdf77ad9d84e2f623525e493beed185d7d7b3093a4f50ec59d", + "source": "bootstrap" + }, + ".github/workflows/issue-hygiene.yml": { + "sha256": "9708caebb5cae5ecb935c2350ddbf8105beaeb12ac6db31de609106ab8f3383b", + "source": "bootstrap" + }, + "AGENTS.md": { + "sha256": "8acfcb8fe4d902e7892abdee9db7073c4f5fe33ac4733813c83a83434885a997", + "source": "bootstrap" + }, + "CLAUDE.md": { + "sha256": "fa083f9f4b9b5a42c3179c0320070314ca7ae120dcf593b85677196e4914cdfe", + "source": "bootstrap" + }, + "CODEOWNERS": { + "sha256": "d5b88668ece5258a10e66ab476d2135250623474ff24d14748d071c5076559b0", + "source": "bootstrap" + }, + "docs/bootstrap/claude-environment.md": { + "sha256": "707dac647e02b7811f0ad99c367fbe9fc946aad3b0dd85366162eb10f39d2bb8", + "source": "bootstrap" + }, + "docs/bootstrap/codex-cloud-environment.md": { + "sha256": "d523693384512cd1b07a2e3467afba4ad45c6a41bc00ae69c01601f6af85f10e", + "source": "bootstrap" + }, + "docs/bootstrap/issue-hygiene.md": { + "sha256": "039344533cc90b966fd7ef6501bf6cfb956b5edc61126ad9c14aad545c315580", + "source": "bootstrap" + }, + "docs/bootstrap/next-steps.md": { + "sha256": "4e471faef1858d9f2c36328f10621dccd6b01fdc2fd33bd6547d494518b469b9", + "source": "bootstrap" + }, + "docs/bootstrap/onboarding.md": { + "sha256": "a223890f60a3d38cd68d320980f4e80c529a5c271053cb24639c68839d2dc92b", + "source": "bootstrap" + }, + "docs/bootstrap/security.md": { + "sha256": "88cde376d14b2526b15f2f1fba874d1bdf3bb91a53e9736bce02020d44b5816a", + "source": "bootstrap" + }, + "project.bootstrap.yaml": { + "sha256": "6bf2342401b92c8e46a051d1e595ef4eb97f6a4febfe52559dbf8920fa588b9b", + "source": "bootstrap" + }, + "scripts/ci/report-issue-hygiene.mjs": { + "sha256": "53bc2252f82e388ec4ba182cab500b78b3f429c304501a55c205ad3f0e534087", + "source": "bootstrap" + }, + "scripts/claude-cloud/setup.sh": { + "sha256": "77a9e57f3dde3cbdbaa2b7385667392463236d79a1cf84b355f1944e08a72eaf", + "source": "bootstrap" + }, + "scripts/claude/setup-devcontainer.sh": { + "sha256": "4ad6cbff01875e6d1cb58e32460d10ca941defa0e02482dd20de65f15ba55211", + "source": "bootstrap" + }, + "scripts/codex-cloud/maintenance.sh": { + "sha256": "8a4f5e11b133539c46dd9d45e74d49b19e0fb092e3620f2af8552e07cf94dabc", + "source": "bootstrap" + }, + "scripts/codex-cloud/setup.sh": { + "sha256": "debc23607cccb92ed096941adc7da777ff794ec8c97d4efa842e592b33c29d10", + "source": "bootstrap" + }, + "SECURITY.md": { + "sha256": "74daa1c5bb75938c109739135fa6c15d948928936b37830e378ad6fc92bc414e", + "source": "bootstrap" + } + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e79b100..ca4f60f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,7 +12,8 @@ while IFS= read -r -d '' staged_file; do staged_files+=("$staged_file") done < <(git diff --cached --name-only --diff-filter=ACMR -z) -for f in "${staged_files[@]}"; do +for f in "${staged_files[@]:-}"; do + [[ -n "$f" ]] || continue case "$f" in *.env|.env.*) if [[ "$f" != *.example ]]; then diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e2316fc..4f18f69 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,8 @@ Refs # + ## Merge Automation diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 594bfbb..7fb684c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: prompt: - description: 'Task for Claude to run in this repository' - required: true - default: 'Review the current branch changes for bugs, CI regressions, and missing tests.' + description: Optional manual task prompt + required: false + type: string issue_comment: types: [created] pull_request_review_comment: @@ -20,61 +20,24 @@ concurrency: permissions: contents: read - pull-requests: read + id-token: write + pull-requests: write + issues: write jobs: claude: if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.review.author_association)) - # Claude has write permissions and reads ANTHROPIC_API_KEY, so keep it on a - # trusted private runner instead of the public shell-only fleet. - runs-on: ['self-hosted', 'private', 'macOS', 'ARM64', 'xcode'] + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) + runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Require Claude auth - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [[ -z "${ANTHROPIC_API_KEY}" ]]; then - echo "Missing repository secret ANTHROPIC_API_KEY. Run /install-github-app in Claude Code or add the secret before using this workflow." >&2 - exit 1 - fi - + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Run Claude Code - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - additional_permissions: "actions: read" - prompt: | - REPO: ${{ github.repository }} - DEFAULT BRANCH: main - - Use CLAUDE.md and docs/bootstrap/onboarding.md as repo policy context. - Keep required PR status checks aligned with CI Gate. - Preserve the split fast and extended validation model. - Shell-safe jobs must use `[self-hosted, linux, shell-only, public]`. - Secret-bearing automation must stay on a trusted private runner. - Docker, service-container, browser, and `container:` jobs require a dedicated self-hosted pool with matching capability labels. - Prefer the smallest safe change and add tests for behavior changes. - - MANUAL TASK: ${{ github.event.inputs.prompt }} - If this is not a manual run, ignore the MANUAL TASK line and respond to the current `@claude` request instead. + # An empty prompt preserves the action's tag mode for @claude events. + prompt: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.prompt || '' }} diff --git a/.github/workflows/issue-hygiene.yml b/.github/workflows/issue-hygiene.yml new file mode 100644 index 0000000..6d58a0b --- /dev/null +++ b/.github/workflows/issue-hygiene.yml @@ -0,0 +1,37 @@ +name: Issue Hygiene Report + +on: + schedule: + - cron: '17 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: read + +jobs: + report: + name: Report Aging Issues + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + - name: Build deterministic issue hygiene report + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + node scripts/ci/report-issue-hygiene.mjs \ + --repo "$GITHUB_REPOSITORY" \ + --json-output issue-hygiene-report.json \ + | tee "$RUNNER_TEMP/issue-hygiene-summary.md" + cat "$RUNNER_TEMP/issue-hygiene-summary.md" >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: issue-hygiene-report + path: issue-hygiene-report.json + if-no-files-found: error + retention-days: 30 diff --git a/AGENTS.md b/AGENTS.md index 64e7bc8..3ba492e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,9 @@ - Always work on a feature branch. Hooks block commits to `main` and `master`; enable them with `git config core.hooksPath .githooks`. - Stack baseline: Generic polyglot. - CI baseline: fast PR checks stay cheap and shell-safe; extended validation runs on `main`, nightly, or manual dispatch. -- Self-hosted runner policy: shell-safe jobs must use `[self-hosted, linux, shell-only, public]`; native repos must use self-hosted runners for required automation, with Docker, service-container, browser, or `container:` workloads routed to dedicated self-hosted capability pools. +- Self-hosted runner policy: private-repository trusted jobs may use their matching capability pool. Public repository security workflows use GitHub-hosted isolation; fork pull-request jobs always remain read-only and GitHub-hosted. - Add or update tests for every interactive, branching, or operator-facing behavior change. +- For a task that may open or update a PR, handle autoreview access before implementation: request required network access immediately and, for a private repository, explicit authorization to send the forthcoming intended PR diff to the external reviewer. At closeout, use the `autoreview` skill against the actual base. Verify every finding, fix accepted in-scope findings, and rerun affected tests and autoreview after changes. Proceed only when no accepted/actionable findings remain, and record the final command and result in the PR validation evidence. If authorization is declined or the skill is unavailable or cannot complete, stop and report the blocker instead of bypassing the gate. - PRs must use the generated pull request template. The required PR gate validates summary, issue linkage, validation evidence, and risk notes. - Never commit real secrets, runtime auth, or machine-local env files. Use templates and GitHub environments instead. diff --git a/SECURITY.md b/SECURITY.md index d58d2b6..000215e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,14 @@ This repository follows the bootstrap-managed security baseline for OMT-Global/a ## Reporting -Open a private security advisory or contact the repository maintainers before disclosing a vulnerability publicly. +Report suspected vulnerabilities through [GitHub private vulnerability reporting](https://github.com/OMT-Global/apw-cli/security/advisories/new). If that form is unavailable, open a public issue titled `Private security contact requested` without vulnerability details; maintainers will establish a confidential channel before accepting the report. Never include exploit details in public issues or discussions. + +## Response Targets + +- Acknowledge a complete report within 3 business days. +- Provide a status update within 10 business days, even when investigation is ongoing. +- Target remediation within 7 days for critical findings, 30 days for high findings, and 90 days for moderate findings. Low-severity findings are scheduled by maintainers. +- Coordinate disclosure timing with the reporter after a fix or documented mitigation is available. ## Baseline diff --git a/docs/bootstrap/issue-hygiene.md b/docs/bootstrap/issue-hygiene.md new file mode 100644 index 0000000..371de70 --- /dev/null +++ b/docs/bootstrap/issue-hygiene.md @@ -0,0 +1,31 @@ +# Report-First Issue Hygiene + +`.github/workflows/issue-hygiene.yml` inventories open issues every Monday and on manual dispatch. It uses only `contents: read` and `issues: read`, writes a complete versioned JSON artifact, and appends a Markdown report capped at 900 KiB to the workflow summary. + +## Aging Rules + +- Fewer than 30 inactive days: current; no report entry. +- At least 30 inactive days: review proposal. +- At least 90 inactive days without a credible next action: close-or-rescope proposal that requires a maintainer decision. +- Automation never comments, labels, closes, reschedules, or otherwise mutates an issue. + +GitHub's `updated_at` timestamp is the inactivity source. Pull requests returned by the issues API are excluded. + +## Preserve A Stale Issue + +Add one structured marker to the issue body. `outcome` or an evidence-shaped `dependency` is required, `checkpoint` must be a future ISO date, and `evidence` must be a canonical public GitHub issue, pull-request, or Actions-run URL without query or fragment data, or a positive numeric `issue:`, `pr:`, or `run:` reference. + +```html + +``` + +The report publishes only the issue number, single-line title, URL, timestamps, checkpoint, and evidence reference. It never emits the issue body or the next-action outcome. + +## Local Fixture + +```sh +node scripts/ci/report-issue-hygiene.mjs \ + --fixture /path/to/issues.json \ + --as-of 2026-07-18T12:00:00Z \ + --json-output issue-hygiene-report.json +``` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index a58a08f..1d8a922 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -12,12 +12,20 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr - Confirm branch protection or rulesets on `main` require one approval, code owner review, and approval from someone other than the most recent pusher. - Confirm branch protection points at the `CI Gate` status. - Confirm `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` are present as the required contributor and PR guidance surfaces. +- Confirm `AGENTS.md` requires the `autoreview` skill against the intended PR diff before an agent opens or updates a PR, and that the PR template records the final command and result. - Confirm the pull request template is present and PR Fast CI validates the required PR description sections before CI Gate can pass. +- Confirm `Issue Hygiene Report` runs weekly with read-only issue permission and retains its JSON evidence artifact. - Confirm `delete branch on merge` and `allow auto-merge` are enabled when the GitHub plan supports them; otherwise record the plan-limit evidence and use the fallback merge-readiness policy. - Fallback merge readiness requires passing or intentionally skipped required checks, satisfied approvals, resolved conversations, no blocking review state, and a manual maintainer merge. +## Public Security Baseline + +- Review `docs/bootstrap/security.md` before changing security workflow events, permissions, or runner labels. +- Confirm dependency review is the only security job reachable from fork pull requests and runs on GitHub-hosted isolation; CodeQL and SBOM jobs must remain trusted-event only and GitHub-hosted. +- Capture the seven required GitHub capability observations before treating remote security controls as verified. +- Confirm `SECURITY.md` private reporting and response targets match the maintained operational policy. ## Environments @@ -27,8 +35,9 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr ## Runner Policy -- Shell-safe jobs must use `[self-hosted, linux, shell-only, public]`. -- Native repos must use self-hosted runners for required automation; Docker, service-container, browser, and `container:` workloads require a dedicated self-hosted runner pool with matching capability labels. +- Private-repository trusted shell-safe jobs use `[self-hosted, linux, shell-only, private]`. +- Public repository security workflows use GitHub-hosted isolation. Fork pull-request jobs always remain read-only and GitHub-hosted. +- Native repos must use self-hosted runners for trusted required automation; Docker, service-container, browser, and `container:` workloads require a dedicated self-hosted runner pool with matching capability labels. - Keep PR checks cheap. Add heavy validation to `scripts/ci/run-extended-validation.sh` instead of the PR lane. - Consume shared security, release, and AI attestation workflows from the control-plane repo once those contracts are pinned for production use. @@ -40,6 +49,20 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr - To retrofit an existing bootstrapped repo, add `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` to `repo.managedPaths` when that repo restricts managed paths, then run `bootstrap apply repo --manifest ./project.bootstrap.yaml`. - Keep these files repo-generic unless project metadata or the manifest requires a stricter local rule. +## Issue Hygiene + +- Review `docs/bootstrap/issue-hygiene.md` before acting on a 30-day review or 90-day close-or-rescope proposal. +- The scheduled workflow is report-only: it never comments, labels, closes, or reschedules issues. +- A 90-day proposal always requires a maintainer decision. Record a structured, evidenced future action when the issue should remain open. + +## Licensing + +- Repository visibility never selects or grants a license. Declare `license.mode` explicitly before Bootstrap manages `LICENSE`. +- Current manifest mode: not declared; Bootstrap will not create, replace, or remove a license. +- Keep `THIRD_PARTY_NOTICES.md` separate from the first-party notice and inventory dependencies, assets, fonts, media, and incorporated source. +- Any existing-license replacement requires legal ownership, contributor, distribution-history, issue, and approver evidence in the manifest. Previously granted rights are not revoked. +- Verify GitHub license detection after publishing an SPDX license. Never describe a proprietary notice as SPDX, OSI approved, or GitHub-recognized. + ## Fleet Reconciliation - Run `bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json` first; this is plan-only and does not write files. diff --git a/docs/bootstrap/security.md b/docs/bootstrap/security.md new file mode 100644 index 0000000..55f5e46 --- /dev/null +++ b/docs/bootstrap/security.md @@ -0,0 +1,22 @@ +# Public Repository Security Model + +## Trust Boundaries + +- Pull requests, including forks, are untrusted input. The pull-request lane runs on GitHub-hosted isolation with read-only repository permissions, does not read GitHub Actions secrets, and runs only dependency review after GitHub provisioning enables the dependency graph and sets `DEPENDENCY_REVIEW_ENABLED=true`. +- Code scanning and SBOM generation run only for trusted default-branch pushes and schedules on GitHub-hosted isolation. +- GitHub-hosted security capabilities are evaluated from a versioned capability snapshot so unsupported plan features remain distinct from repository misconfiguration. + +## Required Controls + +- Dependency graph, Dependabot alerts and security updates, secret scanning, push protection, code scanning, and private vulnerability reporting are required capability observations for public repositories. The dependency-graph observation must also record `dependencyReviewEnabled: true` after provisioning verifies `DEPENDENCY_REVIEW_ENABLED=true`. +- `.github/dependabot.yml` keeps both dependency and GitHub Actions pins updateable. +- `.github/workflows/security.yml` performs dependency review, CodeQL analysis for `javascript-typescript,python,ruby,rust,swift`, and SPDX JSON SBOM generation using immutable action SHAs. +- `SECURITY.md` directs reporters to a private advisory and defines acknowledgement, update, remediation, and coordinated-disclosure targets. + +## Fork Safety + +The security workflow uses `pull_request`, never `pull_request_target`. Its top-level permission is `contents: read`; the only job reachable from a pull request uses a GitHub-hosted runner, has read-only permissions, and has no secret references. Jobs needing `security-events: write` are explicitly excluded from pull-request events. + +## Capability Evidence + +Capture authorized observations for these controls and pass them to `bootstrap conform --github-capabilities `: `dependency-graph`, `dependabot-alerts`, `dependabot-security-updates`, `secret-scanning`, `push-protection`, `code-scanning`, and `private-vulnerability-reporting`. Record `dependencyReviewEnabled: true` only after verifying the repository activation variable. Unsupported controls remain warnings with remediation; available but disabled controls are blocking misconfigurations. Current typed exceptions may waive only their matching `github.` scope. diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 61fd9e4..1bf29ab 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -15,15 +15,17 @@ repo: - SECURITY.md - .githooks/** - .devcontainer/** - - .github/workflows/pr-fast-ci.yml - - .github/workflows/extended-validation.yml - - .github/workflows/claude.yml - - scripts/check-detect-secrets.sh - - scripts/ci/** + - .github/workflows/issue-hygiene.yml - scripts/codex-cloud/** + - scripts/ci/report-issue-hygiene.mjs - scripts/claude-cloud/** - scripts/claude/** - - docs/bootstrap/** + - docs/bootstrap/claude-environment.md + - docs/bootstrap/codex-cloud-environment.md + - docs/bootstrap/next-steps.md + - docs/bootstrap/onboarding.md + - docs/bootstrap/issue-hygiene.md + - docs/bootstrap/security.md docs: readme: true contributing: false @@ -164,12 +166,6 @@ github: dependabot: false secretScanningHints: true ci: - codeqlLanguages: - - javascript-typescript - - python - - ruby - - rust - - swift policy: standard-public runnerPolicy: hybrid-safe nodeVersion: "20" @@ -179,6 +175,12 @@ ci: extendedChecks: - template-review nightlyCron: 0 7 * * * + codeqlLanguages: + - javascript-typescript + - python + - ruby + - rust + - swift additionalWorkflows: [] workflows: prFastCi: true @@ -242,6 +244,7 @@ capabilities: enabled: false containers: enabled: false +exceptions: [] environments: dev: reviewers: [] diff --git a/scripts/check-detect-secrets.sh b/scripts/check-detect-secrets.sh index 7d00917..756133d 100755 --- a/scripts/check-detect-secrets.sh +++ b/scripts/check-detect-secrets.sh @@ -1,80 +1,81 @@ #!/usr/bin/env bash -set -euo pipefail + set -euo pipefail -mode="${1:---all-files}" - -ignore_globs=("scripts/check-detect-secrets.sh") -if [[ -f .detect-secrets-ignore ]]; then - while IFS= read -r ignore_glob; do - if [[ -z "$ignore_glob" ]] || [[ "${ignore_glob:0:1}" == "#" ]]; then - continue + mode="${1:-"--all-files"}" + ignore_globs=("scripts/check-detect-secrets.sh") + if [[ -f .detect-secrets-ignore ]]; then + while IFS= read -r ignore_glob; do + if [[ -z "$ignore_glob" ]]; then + continue + fi + if [[ "${ignore_glob:0:1}" == "#" ]]; then + continue + fi + ignore_globs+=("$ignore_glob") + done < .detect-secrets-ignore fi - ignore_globs+=("$ignore_glob") - done < .detect-secrets-ignore -fi -should_skip_file() { - local candidate="$1" - local ignore_glob - for ignore_glob in "${ignore_globs[@]}"; do - case "$candidate" in - $ignore_glob) - return 0 - ;; - esac - done - return 1 -} + should_skip_file() { + local candidate="$1" + local ignore_glob + for ignore_glob in "${ignore_globs[@]}"; do + case "$candidate" in + $ignore_glob) + return 0 + ;; + esac + done + return 1 + } -files=() -if [[ "$mode" == "--staged" ]]; then - while IFS= read -r -d '' file; do - files+=("$file") - done < <(git diff --cached --name-only --diff-filter=ACMR -z) -else - while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z) -fi + files=() + if [[ "$mode" == "--staged" ]]; then + while IFS= read -r -d '' file; do + files+=("$file") + done < <(git diff --cached --name-only --diff-filter=ACMR -z) + else + while IFS= read -r -d '' file; do + files+=("$file") + done < <(git ls-files -z) + fi -if [[ "${#files[@]}" -eq 0 ]]; then - echo "No files to scan." - exit 0 -fi + if [[ "${#files[@]}" -eq 0 ]]; then + echo "No files to scan." + exit 0 + fi -patterns=( - 'ghp_' - 'github_pat_' - 'sk-live-' - 'sk-proj-' - 'AKIA[0-9A-Z]{16}' - 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' - 'ANTHROPIC_API_KEY=' - 'OPENAI_API_KEY=' - 'SUDO_PASS=' - 'BW_SESSION=' -) + patterns=( + 'ghp_' + 'github_pat_' + 'sk-live-' + 'sk-proj-' + 'AKIA[0-9A-Z]{16}' + 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' + 'OPENAI_API_KEY=' + 'ANTHROPIC_API_KEY=' + 'SUDO_PASS=' + 'BW_SESSION=' + ) -tmp_file="$(mktemp)" -trap 'rm -f "$tmp_file"' EXIT + tmp_file="$(mktemp)" + trap 'rm -f "$tmp_file"' EXIT -for file in "${files[@]}"; do - if [[ ! -f "$file" ]] || should_skip_file "$file"; then - continue - fi - printf '%s\n' "$file" >>"$tmp_file" -done + for file in "${files[@]}"; do + if [[ ! -f "$file" ]] || should_skip_file "$file"; then + continue + fi + printf '%s +' "$file" >>"$tmp_file" + done -failed=0 -while IFS= read -r file; do - for pattern in "${patterns[@]}"; do - if grep -E -q "$pattern" "$file" 2>/dev/null; then - grep -E -n "$pattern" "$file" 2>/dev/null | while IFS= read -r match; do - echo "Potential secret pattern '$pattern' found in $file:$match" >&2 + failed=0 + while IFS= read -r file; do + for pattern in "${patterns[@]}"; do + if grep -E -n "$pattern" "$file" >/dev/null 2>&1; then + echo "Potential secret pattern '$pattern' found in $file" >&2 + failed=1 + fi done - failed=1 - fi - done -done <"$tmp_file" + done <"$tmp_file" -exit "$failed" + exit "$failed" diff --git a/scripts/ci/report-issue-hygiene.mjs b/scripts/ci/report-issue-hygiene.mjs new file mode 100755 index 0000000..4145e3d --- /dev/null +++ b/scripts/ci/report-issue-hygiene.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const INACTIVE_REVIEW_DAYS = 30; +const CLOSE_OR_RESCOPE_DAYS = 90; +const MARKDOWN_SUMMARY_BYTE_LIMIT = 900 * 1024; +const NEXT_ACTION_PATTERN = //gi; + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!["--fixture", "--repo", "--as-of", "--json-output"].includes(argument)) { + throw new Error(`Unknown argument: ${argument}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} requires a value.`); + options[argument.slice(2).replace("-", "_")] = value; + index += 1; + } + if (Boolean(options.fixture) === Boolean(options.repo)) { + throw new Error("Provide exactly one of --fixture or --repo."); + } + return options; +} + +function parseTimestamp(value, label) { + if (typeof value !== "string") throw new Error(`${label} must be an ISO-8601 timestamp.`); + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/); + if (!match) throw new Error(`${label} must be an ISO-8601 timestamp.`); + const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, offsetHourText, offsetMinuteText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const validDate = month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1]; + const validTime = Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59; + const offsetHour = Number(offsetHourText ?? 0); + const offsetMinute = Number(offsetMinuteText ?? 0); + const validOffset = zone === "Z" || (offsetHour <= 14 && offsetMinute <= 59 && (offsetHour < 14 || offsetMinute === 0)); + if (!validDate || !validTime || !validOffset) throw new Error(`${label} must be an ISO-8601 timestamp.`); + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) throw new Error(`${label} must be an ISO-8601 timestamp.`); + return timestamp; +} + +function normalizeIssue(raw) { + if (!raw || typeof raw !== "object") throw new Error("Issue records must be objects."); + const number = raw.number; + const title = raw.title; + const url = raw.html_url ?? raw.url; + const updatedAt = raw.updated_at ?? raw.updatedAt; + if (!Number.isInteger(number) || number <= 0) throw new Error("Issue number must be a positive integer."); + if (typeof title !== "string" || !title.trim()) throw new Error(`Issue #${number} title is required.`); + if (typeof url !== "string" || !/^https:\/\//.test(url)) throw new Error(`Issue #${number} URL must use HTTPS.`); + parseTimestamp(updatedAt, `Issue #${number} updatedAt`); + return { + number, + title: title.replace(/\s+/g, " ").trim(), + url, + updatedAt, + body: typeof raw.body === "string" ? raw.body : "", + isPullRequest: raw.pull_request !== undefined || raw.isPullRequest === true + }; +} + +function parseCheckpoint(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return Number.NaN; + const [year, month, day] = value.split("-").map(Number); + const timestamp = Date.UTC(year, month - 1, day); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day + ? timestamp + : Number.NaN; +} + +function escapeMarkdownText(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\\/g, "\\\\") + .replace(/([`*_[\]{}()#+.!|])/g, "\\$1"); +} + +function validEvidenceReference(value) { + if (typeof value !== "string" || /\s/.test(value)) return false; + if (/^(?:issue|pr|run):[1-9]\d*$/.test(value)) return true; + try { + const url = new URL(value); + const publicGitHubEvidence = /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:issues\/[1-9]\d*|pull\/[1-9]\d*|actions\/runs\/[1-9]\d*)$/.test(url.pathname); + return url.origin === "https://github.com" && !url.username && !url.password && !url.search && !url.hash && publicGitHubEvidence; + } catch { + return false; + } +} + +function credibleNextAction(body, asOfTimestamp) { + let credible; + for (const match of body.matchAll(NEXT_ACTION_PATTERN)) { + try { + const value = JSON.parse(match[1]); + const outcome = typeof value.outcome === "string" && value.outcome.trim().length > 0; + const dependency = validEvidenceReference(value.dependency); + const checkpoint = parseCheckpoint(value.checkpoint); + const evidence = validEvidenceReference(value.evidence); + if ((outcome || dependency) && Number.isFinite(checkpoint) && checkpoint > asOfTimestamp && evidence) { + credible = { checkpoint: value.checkpoint, evidence: value.evidence }; + } + } catch { + // Malformed markers are not evidence and remain reportable at the normal aging threshold. + } + } + return credible; +} + +export function buildIssueHygieneReport(rawIssues, asOf = new Date().toISOString()) { + const asOfTimestamp = parseTimestamp(asOf, "asOf"); + const issues = rawIssues.map(normalizeIssue).filter((issue) => !issue.isPullRequest).sort((left, right) => left.number - right.number); + const results = []; + let current = 0; + let review = 0; + let closeOrRescope = 0; + + for (const issue of issues) { + const inactiveDays = Math.max(0, Math.floor((asOfTimestamp - parseTimestamp(issue.updatedAt, `Issue #${issue.number} updatedAt`)) / 86_400_000)); + if (inactiveDays < INACTIVE_REVIEW_DAYS) { + current += 1; + continue; + } + + const nextAction = credibleNextAction(issue.body, asOfTimestamp); + const proposedAction = inactiveDays >= CLOSE_OR_RESCOPE_DAYS && !nextAction ? "close-or-rescope" : "review"; + const humanDecisionRequired = proposedAction === "close-or-rescope"; + if (humanDecisionRequired) closeOrRescope += 1; + else review += 1; + + results.push({ + ruleId: "PRS-ISSUE-AGING-001", + severity: "warning", + issue: { number: issue.number, title: issue.title, url: issue.url }, + inactiveDays, + proposedAction, + humanDecisionRequired, + mutationAllowed: false, + evidence: [ + `updatedAt=${issue.updatedAt}`, + ...(nextAction ? [`nextActionCheckpoint=${nextAction.checkpoint}`, `nextActionEvidence=${nextAction.evidence}`] : []) + ], + remediation: humanDecisionRequired + ? "A maintainer must close or rescope this issue, or record a credible evidenced next action with a future checkpoint." + : "Review the issue and record a credible evidenced next action with a future checkpoint when work remains." + }); + } + + return { + schemaVersion: 1, + asOf: new Date(asOfTimestamp).toISOString(), + thresholds: { inactiveReviewDays: INACTIVE_REVIEW_DAYS, closeOrRescopeDays: CLOSE_OR_RESCOPE_DAYS }, + summary: { scanned: issues.length, current, review, closeOrRescope }, + results + }; +} + +export function formatIssueHygieneReport(report) { + const lines = [ + `# Issue Hygiene Report`, + "", + `Scanned ${report.summary.scanned} open issues: ${report.summary.current} current, ${report.summary.review} review, ${report.summary.closeOrRescope} close-or-rescope proposal(s).`, + "", + "This report never mutates, closes, labels, or reschedules an issue. Every close-or-rescope proposal requires a maintainer decision." + ]; + let omitted = 0; + for (let index = 0; index < report.results.length; index += 1) { + const result = report.results[index]; + const entry = `- [${result.proposedAction}] [#${result.issue.number}](${result.issue.url}) ${escapeMarkdownText(result.issue.title)} — ${result.inactiveDays} inactive days; ${result.remediation}`; + const omittedIfRejected = report.results.length - index; + const reservedNotice = `\n\n${omittedIfRejected} additional report entries omitted from Markdown; see the complete JSON artifact.`; + const candidate = `${[...lines, "", entry].join("\n")}${reservedNotice}\n`; + if (Buffer.byteLength(candidate, "utf8") > MARKDOWN_SUMMARY_BYTE_LIMIT) { + omitted = report.results.length - index; + break; + } + lines.push("", entry); + } + if (omitted > 0) lines.push("", `${omitted} additional report entries omitted from Markdown; see the complete JSON artifact.`); + return `${lines.join("\n")}\n`; +} + +function nextPageUrl(linkHeader, repo) { + if (!linkHeader) return undefined; + for (const link of linkHeader.split(",")) { + const match = link.match(/^\s*<([^>]+)>\s*;\s*rel="([^"]+)"\s*$/); + if (match?.[2].split(/\s+/).includes("next")) { + const url = new URL(match[1]); + const expectedNamedPath = `/repos/${repo}/issues`; + const isCanonicalIdPath = /^\/repositories\/\d+\/issues$/.test(url.pathname); + if (url.origin !== "https://api.github.com" || (url.pathname !== expectedNamedPath && !isCanonicalIdPath)) { + throw new Error("GitHub issue inventory returned an invalid next-page URL."); + } + return url.href; + } + } + return undefined; +} + +export async function fetchOpenIssues(repo, token, fetchImplementation = fetch) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error("--repo must be owner/name."); + if (!token) throw new Error("GITHUB_TOKEN is required with --repo."); + const issues = []; + const visited = new Set(); + let url = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`; + while (url) { + if (visited.has(url)) throw new Error("GitHub issue inventory returned a pagination loop."); + visited.add(url); + const response = await fetchImplementation(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!response.ok) throw new Error(`GitHub issue inventory failed with HTTP ${response.status}.`); + const pageIssues = await response.json(); + if (!Array.isArray(pageIssues)) throw new Error("GitHub issue inventory returned a non-array response."); + issues.push(...pageIssues); + url = nextPageUrl(response.headers.get("link"), repo); + } + return issues; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const asOf = options.as_of ?? new Date().toISOString(); + const raw = options.fixture + ? JSON.parse(await readFile(options.fixture, "utf8")) + : await fetchOpenIssues(options.repo, process.env.GITHUB_TOKEN); + const issues = Array.isArray(raw) ? raw : raw?.issues; + if (!Array.isArray(issues)) throw new Error("Issue fixture must be an array or an object with an issues array."); + const report = buildIssueHygieneReport(issues, asOf); + if (options.json_output) await writeFile(options.json_output, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(formatIssueHygieneReport(report)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 92f57ef..65db069 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -53,5 +53,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-native-automation-config.sh ./scripts/test-native-app-preflight-config.sh bash ./scripts/test-pr-fast-ci-config.sh +./scripts/test-bootstrap-sidecar.sh echo "APW fast checks passed." diff --git a/scripts/test-bootstrap-sidecar.sh b/scripts/test-bootstrap-sidecar.sh new file mode 100755 index 0000000..fbf999a --- /dev/null +++ b/scripts/test-bootstrap-sidecar.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +node <<'NODE' +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const sidecarPath = path.join(process.cwd(), ".bootstrap", "managed-files.json"); +const sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); + +for (const [managedPath, entry] of Object.entries(sidecar.managedFiles ?? {})) { + const filePath = path.join(process.cwd(), managedPath); + if (!fs.existsSync(filePath)) { + throw new Error(`Managed file is missing: ${managedPath}`); + } + const actual = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + if (actual !== entry.sha256) { + throw new Error(`Managed-file hash mismatch: ${managedPath}`); + } +} + +console.log("Bootstrap managed-file sidecar hashes passed."); +NODE