---
phase: 16-ci-dependency-audit-and-security-checks
plan: 05
type: execute
wave: 2
depends_on: ["16-02", "16-03", "16-04"]
files_modified:
  - .gitea/workflows/ci.yml
autonomous: true
requirements: [CI-03, SEC-01, DEP-01, DEP-02]
must_haves:
  truths:
    - "A new security job runs in parallel with fast-checks: gitleaks scans every PR (including doc-only), while pnpm audit + pnpm outdated run only when changes.outputs.code is true"
    - "The gitleaks PR-diff scan is blocking and uses the committed config + baseline; the base.sha availability assumption is probed before the scan relies on it, with a merge-base fallback"
    - "The security job is wired into the gate aggregator with an individual needs.security.result check that requires success (not success-or-skipped), since gitleaks always runs"
  artifacts:
    - path: ".gitea/workflows/ci.yml"
      provides: "security job (gitleaks + check-audit.mjs + check-outdated.mjs) + updated gate"
      contains: "security:"
  key_links:
    - from: ".gitea/workflows/ci.yml"
      to: "scripts/check-audit.mjs"
      via: "node scripts/check-audit.mjs step (code-gated)"
      pattern: "check-audit"
    - from: ".gitea/workflows/ci.yml gate"
      to: "security job"
      via: "needs.security.result == success check"
      pattern: "needs.security.result"
---

<objective>
Add a dedicated `security` job to the existing PR workflow (`.gitea/workflows/ci.yml`) — parallel to `fast-checks` — that runs gitleaks on every PR (blocking, D-12) and runs `check-audit.mjs` (blocking on unwaived High+Critical, D-04) and `check-outdated.mjs` (advisory-only, D-06) only on code/lockfile-changing PRs. Then wire `security` into the `gate` aggregator with an individual `needs.security.result` check (D-14 / D-15). This realizes the D-11 gating posture: gitleaks and pnpm audit High+Critical are blocking; pnpm outdated is advisory and never gates.

Purpose: Centralizes the new PR-time security/dependency checks into one isolated, parallel job so a secret-leak or unwaived advisory is clearly attributable and does not pollute fast-checks. This is ADDITIVE — it does not restructure the existing changes/fast-checks/api/harness/gate topology. eslint-plugin-security is NOT a step here (it already runs inside the existing fast-checks `pnpm lint` via 16-03 — this plan only relies on that).

Output: The modified `ci.yml`. Consumes the scripts/config from 16-02 (check-audit.mjs, check-outdated.mjs), 16-03 (eslint-plugin-security already in the lint step), and 16-04 (.gitleaks.toml, gitleaks-baseline.json). Honors all Gitea runner constraints: no actions/cache, ubuntu-latest, set -euo pipefail, REGISTRY_PAT naming (n/a here), individual needs.X.result (Gitea #31007).
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@.gitea/workflows/ci.yml
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add the security job (gitleaks always; audit/outdated code-gated) with a base.sha probe</name>
  <read_first>
    - .gitea/workflows/ci.yml (existing job skeletons: fast-checks lines 33-66, api conditional pattern lines 68-72, the `changes`/paths-filter job lines 8-31 — needs.changes.outputs.code)
    - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (the full `security` job skeleton; set -euo pipefail convention; no actions/cache rule; node: import convention)
    - .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Secret Scanning section — gitleaks v8.30.1 install via curl|tar; `gitleaks git --log-opts="--no-merges BASE..HEAD" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1`; fetch-depth:0 requirement; Pitfall 3; Assumption A2 + Open Question 1 — base.sha may be empty on Gitea, fallback `git merge-base $(git rev-parse origin/${{ github.base_ref }}) HEAD`; the security job needs:[changes], if pull_request)
    - scripts/check-audit.mjs and scripts/check-outdated.mjs (created in 16-02 — invoked here)
    - .gitleaks.toml and scripts/gitleaks-baseline.json (created in 16-04 — referenced here)
  </read_first>
  <action>
    In ci.yml add a new `security` job (placed after `harness`, before `gate`) with `runs-on: ubuntu-latest`, `needs: [changes]`, `if: github.event_name == 'pull_request'`. Steps in order: (1) actions/checkout@v4 with `fetch-depth: 0` (Pitfall 3 — base.sha must be local). (2) A "Probe PR base/head SHA" step (always runs) that echoes `github.event.pull_request.base.sha` and `head.sha`, computes `BASE_SHA` from the event context and, if empty, falls back to `git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD`, exporting BASE_SHA and HEAD_SHA to $GITHUB_ENV (Assumption A2 / OQ-1). (3) Install gitleaks: `set -euo pipefail`, pin VERSION=8.30.1, curl the linux_x64 tarball, tar -xz gitleaks, chmod +x, mv to /usr/local/bin. (4) "Secret scan (PR diff, blocking)" always-runs: `set -euo pipefail`, run `gitleaks git --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --report-path /tmp/gitleaks-pr-report.json --exit-code 1`. (5) actions/setup-node@v4 node 22, (6) corepack enable pnpm, (7) pnpm install --frozen-lockfile, (8) `node scripts/check-audit.mjs`, (9) `node scripts/check-outdated.mjs` — steps 5-9 EACH carry `if: needs.changes.outputs.code == 'true'` (step-level, NOT job-level — D-12 so gitleaks still runs on doc-only PRs). Do NOT add actions/cache. Every multi-line run block starts with `set -euo pipefail`. Commit: `ci(16-05): add security job (gitleaks always; audit/outdated code-gated)`.
  </action>
  <verify>
    <automated>command -v yq >/dev/null 2>&1 && yq '.jobs.security' .gitea/workflows/ci.yml >/dev/null || python3 -c "import yaml,sys; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); j=d['jobs']['security']; assert j['needs']==['changes']; print('security job parses OK')"</automated>
  </verify>
  <acceptance_criteria>
    - ci.yml has a `security` job with `needs: [changes]`, `if: github.event_name == 'pull_request'`, and `fetch-depth: 0` checkout
    - A base/head SHA probe step computes BASE_SHA with a `git merge-base` fallback when the event context is empty
    - gitleaks install + scan steps have NO `if:` (always run, D-12); the gitleaks scan references --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1
    - The pnpm/setup-node/check-audit/check-outdated steps each carry `if: needs.changes.outputs.code == 'true'`
    - No `actions/cache` in the security job; every `run: |` block starts with `set -euo pipefail`
  </acceptance_criteria>
  <done>The security job runs gitleaks unconditionally and the dependency checks behind the code filter, with a base.sha probe + fallback.</done>
</task>

<task type="auto">
  <name>Task 2: Wire the security job into the gate aggregator (individual needs.security.result check)</name>
  <read_first>
    - .gitea/workflows/ci.yml (the gate job lines 345-367 — `needs: [fast-checks, changes, api, harness]`, the individual needs.X.result checks, the #31007 wildcard-bug comment)
    - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Updated gate needs list + security check; the rule that security must require SUCCESS, not "success OR skipped", because gitleaks always runs)
  </read_first>
  <action>
    In ci.yml update the `gate` job: add `security` to its `needs:` list (so it becomes `needs: [fast-checks, changes, api, harness, security]`). In the gate shell script, add a NEW individual check after the existing fast-checks check and BEFORE the `for result in ... api ... harness` loop: if `needs.security.result != success` then echo the result and `exit 1`. Do NOT add security to the success-OR-skipped loop (the api/harness loop) — security always runs (gitleaks is unconditional), so it must strictly require success per Gitea #31007 individual-check convention. Leave the api/harness loop unchanged. Commit: `ci(16-05): wire security job into gate aggregator`.
  </action>
  <verify>
    <automated>python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); g=d['jobs']['gate']; assert 'security' in g['needs'], 'security not in gate needs'; print('gate needs OK')" && grep -q "needs.security.result" .gitea/workflows/ci.yml && echo CHECK-OK</automated>
  </verify>
  <acceptance_criteria>
    - gate `needs:` includes `security`
    - The gate script has an individual `needs.security.result` check that exits 1 unless it equals `success`
    - security is NOT folded into the api/harness success-or-skipped loop
    - The existing fast-checks / api / harness gate logic is unchanged
  </acceptance_criteria>
  <done>The gate requires the security job to succeed via an individual result check, consistent with the #31007 workaround.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| PR author → main branch | The PR workflow is the enforcement point before code reaches the trusted main branch |
| CI runner network → external download | gitleaks binary is fetched from GitHub releases at a pinned version |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-15 | Information Disclosure | A secret introduced in a PR diff (including a doc/config-only PR) reaches main | mitigate | D-02/D-12: gitleaks runs unconditionally in the security job on every PR with --exit-code 1 (Task 1); gate requires security success (Task 2) |
| T-16-16 | Tampering | Unwaived High/Critical dependency advisory merges to main | mitigate | D-04: check-audit.mjs runs code-gated and exits 1 on unwaived High+Critical (Task 1); gate blocks |
| T-16-17 | Repudiation | gitleaks silently scans nothing (fetch-depth:1 → empty base.sha → no commits in range → exit 0) | mitigate | Pitfall 3 + A2: fetch-depth:0 + a base.sha probe with merge-base fallback (Task 1) ensures the diff range is real |
| T-16-SC | Tampering | gitleaks binary download from GitHub releases could be substituted | mitigate | Version pinned to 8.30.1; download from the official gitleaks/gitleaks releases path (Task 1). Note: no checksum verification this phase — accepted residual for a pinned tag from the canonical source |
</threat_model>

<verification>
- ci.yml parses as valid YAML; `jobs.security` exists with needs:[changes]
- gitleaks steps have no `if:`; audit/outdated steps are code-gated
- gate `needs:` includes security and the script has an individual needs.security.result==success check
- No actions/cache; set -euo pipefail on every new multi-line run block
- Live proof (a deliberate-secret PR failing the gate, and a doc-only PR still running gitleaks) is a phase-verification manual check, not a unit test
</verification>

<success_criteria>
- security job added parallel to fast-checks; gitleaks always, audit/outdated code-gated
- base.sha probe + merge-base fallback present
- gate requires security success via an individual result check
- Fully additive — existing topology untouched; all runner constraints honored
</success_criteria>

<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-05-SUMMARY.md` when done.
</output>
