# Harden Supply Chain Sec

> Harden software supply chain security by configuring minimum release age across package managers. Auto-detects active managers or accepts explicit argument.

- Skill: `waterplanai/harden-supply-chain-sec` (Agent Skill)
- Install (CLI): `npx skillmds@latest add waterplanai/harden-supply-chain-sec`
- Raw SKILL.md: https://api.skillmd.com/api/skills/waterplanai/harden-supply-chain-sec/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: WaterplanAI (https://skillmd.com/u/waterplanai)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/waterplanai/harden-supply-chain-sec

---


# harden-supply-chain-sec

Harden software supply chain security by configuring minimum release age policies across package managers.

## Invocation

```
/harden-supply-chain-sec [<manager>|auto] [global|project] [<duration>] [--exclude pkg1,pkg2] [--guided] [--harden]
```

## Arguments

| Arg | Required | Default | Description |
|-----|----------|---------|-------------|
| `manager` | No | `auto` | One of: `pnpm`, `yarn`, `bun`, `npm`, `uv`, `all`, `auto`. `auto` detects from project. `all` = all detected managers. |
| `scope` | No | `project` | `global`, `project`, or `both`. When `global`, skip project root detection; use manager-specific global config paths. When `both`, run project scope first, then global scope (two passes through Sections 4-8a). |
| `duration` | No | `7d` | Human-friendly: `7d`, `24h`, `1w`, `72h`, `3d`. Default is 7 days. |
| `--exclude` | No | (none) | Comma-separated package names. Per-manager semantics differ (see Section 9). |
| `--guided` | No | `false` | Interactive mode: AskUserQuestion at each decision point. Args are pre-filled defaults, not skips. |
| `--harden` | No | `false` | Enable post-config security hardening (Sections 11-13). |

## Behavior

- You are a senior security engineer hardening supply chain configuration.
- Always show dry-run before any writes. Never write without explicit confirmation.
- Fail-open on errors: warn and skip, do not block the user.
- Detection and dry-run phases are read-only. Do not install, enable, or update tooling during preflight.
- Any install/update, audit-tool installation, or lockfile-regeneration command requires a separate explicit confirmation after the exact command is shown.
- Never recommend or run `curl | sh` / `curl | bash` installers.
- Be project-agnostic: no hardcoded paths, repos, or organization names.
- If `--guided` is present, always enter guided mode regardless of other args.
- If `--harden` is present, proceed to hardening gates after config writes.

## CRITICAL: Execution Order

Follow these sections in exact order:
1. Parse arguments (Section 1). If no args: auto-enter guided mode.
2. Guided mode prompts if `--guided` or no args (Section 10)
3. Project root resolution (Section 5)
4. Manager detection and version check (Sections 7, 3)
5. Frozen-lockfile detection (Section 6)
6. Config value computation (Section 4)
7. Dry-run display and Gate 1 confirmation (Section 8)
8. Config writing on confirmation (Section 4)
9. Post-apply summary (Section 8)
10. Post-apply verification (Section 8a)
11. If `--harden` (or selected at Gate 1): Gates 2-2b-3-4 (Sections 11-11b-12-13)
12. If `scope=project`: offer commit (Section 8b)
13. Preferred manager suggestions (Section 15)

---

## Section 1: Parse Arguments and Duration Normalization

Parse the invocation arguments. Apply defaults for missing args:
- `manager` -> `auto`
- `scope` -> `project`
- `duration` -> `7d`
- `--exclude` -> empty
- `--guided` -> false
- `--harden` -> false

### No-Args Auto-Guided

If the skill is invoked with NO arguments (bare `/harden-supply-chain-sec`), automatically enter guided mode (Section 10). This is equivalent to `--guided` being implicitly set. If ANY argument is provided, use the standard defaults and require explicit `--guided` for interactive mode.

### Duration Normalization

Parse human-friendly duration input and convert to each manager's native unit.

**Parsing rules:**
- `Xd` = X days
- `Xh` = X hours
- `Xw` = X weeks (X * 7 days)
- If no unit suffix, assume days
- Minimum: 1 hour. Error if duration < 1 hour.

**Conversion table** (compute at runtime):

| Input | Total hours | pnpm (minutes) | Bun (seconds) | Yarn (duration str) | npm (days, ceil) | uv (duration str) |
|-------|-------------|----------------|---------------|---------------------|------------------|--------------------|
| `7d` | 168 | 10080 | 604800 | `"7d"` | 7 | `"7 days"` |
| `3d` | 72 | 4320 | 259200 | `"3d"` | 3 | `"3 days"` |
| `24h` | 24 | 1440 | 86400 | `"1d"` | 1 | `"24 hours"` |
| `1w` | 168 | 10080 | 604800 | `"7d"` | 7 | `"7 days"` |
| `12h` | 12 | 720 | 43200 | `"12h"` | 1 (ceil) | `"12 hours"` |
| `25h` | 25 | 1500 | 90000 | `"1d1h"` | 2 (ceil) | `"25 hours"` |

**Formulas:**
- `total_hours` = parse input to hours
- pnpm: `total_hours * 60` (integer minutes)
- Bun: `total_hours * 3600` (integer seconds)
- Yarn: if `total_hours >= 24` and evenly divisible by 24 -> `"Xd"` (where X = total_hours / 24), else if `total_hours >= 24` -> `"XdYh"` (where X = floor(total_hours / 24), Y = total_hours % 24), else -> `"Xh"`
- npm: `ceil(total_hours / 24)` (integer days, minimum 1). If `total_hours` is not evenly divisible by 24, show warning: "npm rounds <N>h up to <ceil> days (<ceil*24>h effective protection)"
- uv: if evenly divisible by 24 -> `"X days"` (where X = total_hours / 24), else -> `"N hours"` (where N = total_hours). For uv < 0.9.17, use ISO 8601 timestamp instead (compute as UTC now + duration).

## Section 2: Supported Managers Reference

### Manager Configuration Map

| Manager | Config key | Unit | Min version | Config file (project) | Config file (global) |
|---------|-----------|------|-------------|-----------------------|---------------------|
| pnpm | `minimumReleaseAge` (YAML) / `minimum-release-age` (rc) | minutes (integer) | `>=10.16.0` | `pnpm-workspace.yaml` | `~/.config/pnpm/rc` (Linux), `~/Library/Preferences/pnpm/rc` (macOS), or `~/.npmrc` |
| Yarn v4 | `npmMinimalAgeGate` | duration string | `>=4.10.0` | `.yarnrc.yml` | `~/.yarnrc.yml` (verified: Yarn 4.13.0 reads and enforces `npmMinimalAgeGate` from home `.yarnrc.yml`) |
| Bun | `install.minimumReleaseAge` | seconds (integer) | `>=1.3.0` | `bunfig.toml` | `~/.bunfig.toml` |
| npm | `min-release-age` | days (integer) | `>=11.10.0` | `.npmrc` | `~/.npmrc` (NOTE: `npm config get min-release-age` may return `null` even when set — verify via `npm config get before --global` which shows dynamic `now - duration` timestamp) |
| uv | `exclude-newer` | duration string or ISO 8601 | `>=0.4.0` (duration strings: `>=0.9.17`) | `pyproject.toml` under `[tool.uv]` | `~/.config/uv/uv.toml` |

### Lockfile Detection Map

| Manager | Lockfile | Config markers |
|---------|----------|----------------|
| pnpm | `pnpm-lock.yaml` | `pnpm-workspace.yaml` |
| Yarn v4 | `yarn.lock` | `.yarnrc.yml` |
| Bun | `bun.lock` (legacy: `bun.lockb`) | `bunfig.toml` |
| npm | `package-lock.json` | `.npmrc` |
| uv | `uv.lock` | `pyproject.toml` with `[tool.uv]` section |

### Exclusion Keys

| Manager | Exclusion config key | Semantics | Warning |
|---------|---------------------|-----------|---------|
| pnpm | `minimumReleaseAgeExclude` (array in `pnpm-workspace.yaml`) | Bypasses age gate only | (none) |
| Bun | `install.minimumReleaseAgeExcludes` (array in `bunfig.toml`) | Bypasses age gate only | (none) |
| Yarn v4 | `npmPreapprovedPackages` (array in `.yarnrc.yml`) | Bypasses ALL package gates, not just age | WARN: "Yarn's npmPreapprovedPackages bypasses all package validation gates (audit, age, signature), not just the age gate." |
| npm | (none) | No exclusion mechanism | WARN: "npm does not yet support min-release-age exclusions." |
| uv | `exclude-newer-package` (table in `pyproject.toml` `[tool.uv]`) | Per-package cutoff date/duration override (different model) | WARN: "uv uses per-package cutoff overrides, not a bypass list. Each excluded package gets its own exclude-newer-package entry." |

## Section 3: Version Check Procedure

For each manager to be configured, run the version check BEFORE any config writes.

### Steps

1. Run via Bash: `<manager> --version`
   - If command fails (exit code != 0): check Corepack fallback for Yarn (see below).
   - If still fails: manager is NOT installed. Record status: `NOT_INSTALLED`. WARN and SKIP.
2. **Yarn v4 Corepack detection** (special case):
   - `yarn --version` may return nothing or Yarn Classic (1.x) because Yarn v4 is managed via Corepack, NOT the `yarn` npm package.
   - If `yarn --version` fails or returns `1.x`: run `corepack yarn --version` as fallback.
   - If Corepack is not installed: run `corepack --version` to check, record the prerequisite command, and WARN -- but do NOT run `corepack enable` during detection.
   - If Corepack resolves Yarn v4 (>= 4.x): use that version. Record install command as `corepack prepare yarn@<version> --activate`.
   - If Corepack is unavailable and Yarn is not installed: status = `NOT_INSTALLED`, but include in update commands: `corepack enable && corepack prepare yarn@<version> --activate` for later explicit confirmation.
3. Parse version string from output:
   - pnpm: output is just the version number (e.g., `10.16.0`)
   - yarn: output is just the version number (e.g., `4.13.0`) — may come from Corepack fallback
   - bun: output format `X.Y.Z` (e.g., `1.3.0`)
   - npm: output is just the version number (e.g., `11.10.0`)
   - uv: output format `uv X.Y.Z` -- extract the version after `uv `
4. Compare installed version against minimum version from Section 2 table using semver comparison:
   - Split both versions on `.` into `[major, minor, patch]`
   - Compare major first, then minor, then patch
   - If installed >= minimum: status = `OK`
   - If installed < minimum: status = `TOO_OLD`. WARN with message: "Installed <manager> <version> is below minimum <min_version> required for minimum release age support. Skipping." SKIP this manager.
5. Special case for uv:
   - If installed >= 0.9.17: use duration string format (e.g., `"7 days"`)
   - If installed >= 0.4.0 but < 0.9.17: use ISO 8601 timestamp format. Compute as: current UTC time + duration. Format: `YYYY-MM-DDTHH:MM:SSZ`
   - If installed < 0.4.0: `TOO_OLD`, SKIP.
6. Build version check table for dry-run display (see Section 8).

### Manager Installation/Update Age Gate (Self-Referential Rule)

When the version check reveals a manager needs installation or update (status =
`NOT_INSTALLED` or `TOO_OLD`), any suggestion to install or update that manager
MUST itself respect the minimum release age being configured.

**Before suggesting a manager install/update:**
1. Query the manager's own package registry for its latest version publish date.
   - npm/bun/pnpm: `npm view <manager> time --json` (check the version's publish timestamp)
   - yarn: Yarn v4 is distributed via Corepack, NOT the `yarn` npm package. Query the Yarn GitHub releases API: `gh api repos/yarnpkg/berry/releases --jq '.[].tag_name'` or check https://repo.yarnpkg.com/tags. The `npm view yarn time --json` returns Classic (1.x) metadata and must NOT be used for Yarn v4 version lookups.
   - uv: `pip index versions uv --pre` or check PyPI JSON API `https://pypi.org/pypi/uv/json`
2. If the latest version was published LESS than the configured duration ago:
   - WARN: "<manager> latest version <version> was published <N days> ago, which is below the configured minimum release age of <duration>. Recommend waiting or pinning to an older verified version."
   - Suggest the most recent version that DOES meet the age threshold.
3. If the latest version meets the age threshold: suggest it normally.
4. When presenting install/update guidance, first display the exact command or manual steps. Only execute a command after an explicit confirmation step. Never recommend or run `curl | sh` / `curl | bash` installers.

This rule ensures the hardening tool does not undermine its own security posture
by recommending freshly-published manager binaries.

## Section 4: Config Writing Procedures

For EACH verified manager (status = OK), follow the per-manager procedure below.
All writes happen ONLY after Gate 1 confirmation (Section 8).

### General Safety Pattern

1. READ the target config file (if it exists) using the Read tool.
2. EXTRACT the current value for the manager's config key.
3. If current value equals the proposed value: record as "(unchanged)", SKIP write.
4. COMPUTE the unified diff showing the change.
5. Store diff for dry-run display (Section 8).
6. On Gate 1 confirmation: apply the write.
7. POST-WRITE verification: re-read the file, confirm the value matches the proposed value.
8. If verification fails: WARN with details but do NOT retry automatically.

### Hook Denial Recovery

If a Write or Edit operation is DENIED by a hook (e.g., `write-scope-guardian`,
`supply-chain-guardian`, or any other security hook), do NOT retry or attempt to
bypass the hook. Instead:

1. **STOP** all write operations immediately.
2. **Display** the exact manual steps the user must perform themselves:
   ```
   ================================================================
   BLOCKED: A security hook denied the write operation.
   ================================================================

   Hook: <hook_name> (if identifiable from error)
   File: <target_file>
   Change: <key> = <value>

   To apply this change manually, run:

     <exact shell command or editor instruction for this specific change>

   For example:
     echo '<config_line>' >> <target_file>
     -- OR --
     Open <target_file> and add/update: <key>: <value>

   After making the change, come back and confirm.
   ================================================================
   ```
3. **Ask** using AskUserQuestion:
   - header: `Manual update`
   - question: "I was blocked from writing to <target_file>. The manual steps are shown above. Confirm when you have applied the change."
   - options:
     - label: "Done"
     - description: "I applied the change manually"
     - label: "Skip"
     - description: "Skip this manager and continue with the rest"
4. **If "Done"**: run post-check verification using CLI commands (NOT file re-reads,
   since the same hook may block reads too):
   - pnpm: `pnpm config get minimum-release-age`
   - npm: `npm config get before --global` (expect dynamic timestamp; `min-release-age` returns `null` — display quirk)
   - bun/uv/yarn: use Section 8a functional test (preferred) or file read if allowed
   - If correct: display "Verified: <key> = <value>." and continue.
   - If NOT correct: display the exact discrepancy and re-ask (max 2 retries, then WARN and continue).
5. **If "Skip"**: record as "Skipped (hook denied)" in the post-apply summary and continue.

This pattern applies to ALL write operations: config writes (Section 4), hardening
policy writes (Sections 11-13), and any other file modifications.

### pnpm

**Project scope:**
- File: `<project_root>/pnpm-workspace.yaml`
- If file does not exist: create it with content:
  ```yaml
  packages: []
  minimumReleaseAge: <minutes>
  ```
- If file exists: use Edit tool to add/update `minimumReleaseAge: <minutes>` as a top-level key.
- Value: integer minutes (from Section 1 conversion).

**Global scope:**
- pnpm supports global configuration via rc files using kebab-case key names.
- Detect OS and resolve the global rc path:
  - macOS: `~/Library/Preferences/pnpm/rc`
  - Linux: `~/.config/pnpm/rc`
  - Windows: `~/AppData/Local/pnpm/config/rc`
  - Fallback: `~/.npmrc` (pnpm reads this too)
- If the rc file does not exist: create it with content:
  ```ini
  minimum-release-age=<minutes>
  ```
- If the rc file exists: add/update the `minimum-release-age=<minutes>` line.
- Value: integer minutes (same as project scope).
- NOTE: The rc file uses kebab-case (`minimum-release-age`), NOT camelCase (`minimumReleaseAge`).

**Exclusions (if `--exclude`):**
- Add `minimumReleaseAgeExclude` array to `pnpm-workspace.yaml`:
  ```yaml
  minimumReleaseAgeExclude:
    - "package-name-1"
    - "package-name-2"
  ```
- If key already exists: merge new entries (avoid duplicates).

### Yarn v4

**Project scope:**
- File: `<project_root>/.yarnrc.yml`
- If file does not exist: create it with content:
  ```yaml
  npmMinimalAgeGate: "<duration_string>"
  ```
- If file exists: use Edit tool to add/update `npmMinimalAgeGate: "<duration_string>"`.
- Value: duration string from Section 1 conversion (e.g., `"7d"`).

**Global scope:**
- File: `~/.yarnrc.yml`
- Verified: Yarn 4.13.0 reads and enforces `npmMinimalAgeGate` from `~/.yarnrc.yml`.
- If file does not exist: create it with content:
  ```yaml
  npmMinimalAgeGate: "<duration_string>"
  ```
- If file exists: use Edit tool to add/update `npmMinimalAgeGate: "<duration_string>"`.
- Value: duration string from Section 1 conversion (same as project scope).
- NOTE: `~/.yarnrc.yml` causes Yarn to treat `~` as a project root. This is harmless
  for the age gate setting but may affect other Yarn behaviors if the home directory
  contains a `package.json`. Display a note in the dry-run: "Global `~/.yarnrc.yml`
  will be created/updated. This is safe for `npmMinimalAgeGate` but note that Yarn
  treats directories with `.yarnrc.yml` as project roots."

**Exclusions (if `--exclude`):**
- Add `npmPreapprovedPackages` array to `.yarnrc.yml`:
  ```yaml
  npmPreapprovedPackages:
    - "package-name-1"
    - "package-name-2"
  ```
- WARN: "Yarn's npmPreapprovedPackages bypasses ALL package validation gates (audit, age, signature), not just the age gate. Verify this is intended."

### Bun

**Project scope:**
- File: `<project_root>/bunfig.toml`
- If file does not exist: create it with content:
  ```toml
  [install]
  minimumReleaseAge = <seconds>
  ```
- If file exists: use Edit tool to add/update `minimumReleaseAge = <seconds>` under the `[install]` section.
  - If `[install]` section does not exist: add it.
- Value: integer seconds (from Section 1 conversion).

**Global scope:**
- File: `~/.bunfig.toml`
- Same format as project scope.

**Exclusions (if `--exclude`):**
- Add under `[install]` section:
  ```toml
  minimumReleaseAgeExcludes = ["package-name-1", "package-name-2"]
  ```

### npm

**Project scope:**
- File: `<project_root>/.npmrc`
- If file does not exist: create it with content:
  ```ini
  min-release-age=<days>
  ```
- If file exists: use Edit tool to add/update `min-release-age=<days>`.
  - `.npmrc` uses `key=value` format (INI-style, no spaces around `=`).
  - Preserve existing lines and comments.
- Value: integer days (from Section 1 conversion, ceiling rounding).

**Global scope:**
- File: `~/.npmrc`
- Same format as project scope.
- **Credential guardian fallback**: `.npmrc` may contain auth tokens and be blocked by
  security hooks (credential guardian). If direct Read/Write access is denied:
  - Use `npm config set min-release-age <days> --global` to write.
  - Use `npm config get before --global` to verify (returns dynamic `now - duration`
    timestamp; `npm config get min-release-age` may display `null` even when set — this
    is a display quirk in npm 11.x, not a failure).

**Exclusions (if `--exclude`):**
- WARN: "npm does not yet support min-release-age exclusions. There is no mechanism to exempt specific packages."
- Do NOT write any exclusion config.

### uv

**Project scope:**
- File: `<project_root>/pyproject.toml`
- Section: `[tool.uv]`
- If file does not exist AND (`uv.lock` exists OR user explicitly requested uv): create `pyproject.toml` with:
  ```toml
  [tool.uv]
  exclude-newer = "<value>"
  ```
- If file exists but no `[tool.uv]` section: add the section with the key.
- If file exists and `[tool.uv]` section exists: add/update `exclude-newer = "<value>"` within the section.
- Value depends on uv version (from Section 3):
  - uv >= 0.9.17: duration string, e.g., `"7 days"`
  - uv >= 0.4.0 but < 0.9.17: ISO 8601 timestamp, e.g., `"2026-04-08T00:00:00Z"`

**Global scope:**
- File: `~/.config/uv/uv.toml`
- If file does not exist: create it with:
  ```toml
  exclude-newer = "<value>"
  ```
- If file exists: add/update `exclude-newer` key.
- Note: global `uv.toml` does NOT use `[tool.uv]` section -- it is a flat TOML.

**Exclusions (if `--exclude`):**
- WARN: "uv uses per-package cutoff overrides via exclude-newer-package, not a bypass list. Each excluded package gets its own cutoff entry."
- Add to `[tool.uv]` section in `pyproject.toml` (or flat in `uv.toml` for global):
  ```toml
  [tool.uv.exclude-newer-package]
  package-name-1 = "<far_future_timestamp>"
  package-name-2 = "<far_future_timestamp>"
  ```
- The value for excluded packages should be a far-future timestamp (e.g., `"2099-12-31T23:59:59Z"`) to effectively bypass the age gate.

## Section 5: Project Root Resolution

If `scope=global`: SKIP this section entirely. Global config paths are absolute (Section 4).
If `scope=both`: resolve project root (needed for the project-scope pass).

### Precedence (highest to lowest)

1. **User-specified root**: If user provided an explicit path argument, use it.
2. **Manager-specific root markers** (scan upward from CWD):
   - pnpm: nearest `pnpm-workspace.yaml` (pnpm is always root-scoped)
   - Yarn: nearest `.yarnrc.yml`
   - uv: nearest `pyproject.toml` containing `[tool.uv]` section, or nearest `uv.lock`
   - npm: nearest `package.json`
   - Bun: nearest `package.json`
3. **VCS root**: nearest `.git` directory (fallback)
4. **`package.json#packageManager`**: use as a hint/tie-breaker to identify which manager, NOT as a root override.

### Resolution Steps

1. Determine CWD via Bash: `pwd`
2. For each manager in the detection set, use Bash to scan upward:
   ```bash
   # Example for pnpm -- find nearest pnpm-workspace.yaml
   dir="$(pwd)"
   while [ "$dir" != "/" ]; do
     [ -f "$dir/pnpm-workspace.yaml" ] && echo "$dir" && break
     dir="$(dirname "$dir")"
   done
   ```
3. If multiple managers resolve to different roots:
   - Use manager-specific roots for each manager's config writes (monorepo case).
   - Display all detected roots in dry-run.
4. If no markers found: fall back to VCS root (`.git`).
5. If still ambiguous (e.g., no `.git` either): use AskUserQuestion to ask user for the project root.

### Monorepo Handling

- In monorepos, different managers may have different root locations.
- pnpm always writes to workspace root (`pnpm-workspace.yaml` location).
- Other managers write to the directory containing their respective config files.
- Display each manager's resolved root in the dry-run.

## Section 6: Frozen-Lockfile Detection

Only run when `scope=project`. Detect CI/build configurations that use frozen-lockfile install commands.

### Scan Targets

Use Glob and Grep to scan these files at project root:

| Glob pattern | Description |
|-------------|-------------|
| `.github/workflows/*.yml` | GitHub Actions |
| `.github/workflows/*.yaml` | GitHub Actions (alt extension) |
| `.gitlab-ci.yml` | GitLab CI |
| `Jenkinsfile` | Jenkins |
| `Dockerfile*` | Docker (Dockerfile, Dockerfile.dev, etc.) |
| `Makefile` | Make |
| `package.json` | npm scripts section |

### Patterns to Search (per manager)

Use Grep with these patterns. Record each match as `file:line`.

| Manager | Grep pattern (regex) |
|---------|---------------------|
| npm | `npm\s+ci\b` |
| pnpm | `pnpm\s+install\s+--frozen-lockfile` |
| Yarn | `yarn\s+install\s+--immutable` |
| Bun | `bun\s+ci\b` or `bun\s+install\s+--frozen-lockfile` |
| uv | `uv\s+sync\s+--frozen` |

### Output

Build a table of detected frozen-lockfile usages:

```
| Manager | Command found | Location |
|---------|--------------|----------|
| npm | npm ci | .github/workflows/ci.yml:23 |
| uv | uv sync --frozen | Dockerfile:14 |
```

If no frozen-lockfile usage detected: omit the table from dry-run.

### Post-Apply Warning

After config writes, if any frozen-lockfile usage was detected, display:

```
WARNING: Frozen-lockfile CI commands detected (see above).
After applying minimum release age, you MUST re-resolve your lockfile(s)
before committing, so the lockfile reflects the new policy:

  npm:  npm install
  pnpm: pnpm install
  yarn: yarn install
  bun:  bun install
  uv:   uv sync

Then commit the updated lockfile(s).
Do NOT run the install commands automatically -- review changes first.
```

Only list managers that have both: (a) config being written AND (b) frozen-lockfile detected.

## Section 7: Auto-Detection Procedure

When `manager=auto` (default), detect which package managers are active in the project.

### Detection Steps

1. Resolve project root (Section 5).
2. Use Glob to check for lockfiles and config files at project root:

   ```
   Glob(pattern="pnpm-lock.yaml", path="<project_root>")
   Glob(pattern="yarn.lock", path="<project_root>")
   Glob(pattern="bun.lock", path="<project_root>")
   Glob(pattern="bun.lockb", path="<project_root>")
   Glob(pattern="package-lock.json", path="<project_root>")
   Glob(pattern="uv.lock", path="<project_root>")
   Glob(pattern="pnpm-workspace.yaml", path="<project_root>")
   Glob(pattern=".yarnrc.yml", path="<project_root>")
   Glob(pattern=".pnp.cjs", path="<project_root>")
   Glob(pattern=".yarn", path="<project_root>")
   Glob(pattern="bunfig.toml", path="<project_root>")
   Glob(pattern=".npmrc", path="<project_root>")
   Glob(pattern="pyproject.toml", path="<project_root>")
   Glob(pattern="package.json", path="<project_root>")
   ```

3. Map detection results to managers:
   - pnpm: detected if `pnpm-lock.yaml` OR `pnpm-workspace.yaml` exists
   - Yarn v4: detected if `yarn.lock` exists AND any of the following v4 markers are present:
     - `.yarnrc.yml` exists, OR
     - `package.json` contains a `"packageManager": "yarn@4.x"` field, OR
     - `.pnp.cjs` or `.yarn/` directory exists (Corepack-managed Yarn 4 projects may have these without `.yarnrc.yml` or `packageManager`), OR
     - `yarn --version` returns a `4.x` version (runtime check as last resort)
     If only `yarn.lock` exists with NONE of the above markers AND the runtime version check fails or returns 1.x: WARN: "yarn.lock found but cannot confirm Yarn v4. Skipping. Use `manager=yarn` to force."
   - Bun: detected if `bun.lock` OR `bun.lockb` (legacy) OR `bunfig.toml` exists
   - npm: detected if `package-lock.json` exists. If other JS lockfiles (pnpm/yarn/bun) also exist, WARN: "package-lock.json found alongside <other lockfile>. npm included but may be secondary in this monorepo. Use `manager=npm` to configure explicitly if auto-detection excludes it." Still include npm in detection -- do NOT silently skip.
   - uv: detected if `uv.lock` exists OR (`pyproject.toml` exists with `[tool.uv]` section)

4. For uv detection via `pyproject.toml`: Read the file and check if `[tool.uv]` section exists using Grep:
   ```
   Grep(pattern="\\[tool\\.uv\\]", path="<project_root>/pyproject.toml")
   ```

5. Also detect warning-only managers:
   - Cargo: `Cargo.lock` or `Cargo.toml` exists
   - pip: `requirements.txt` or `setup.py` or `setup.cfg` exists (without uv markers)
   - Go: `go.sum` or `go.mod` exists

6. If NO managers detected: ERROR with message listing all files scanned and paths checked. Use AskUserQuestion to ask user to specify manager explicitly.

### When `manager=all`

Run auto-detection, then configure ALL detected supported managers (not warning-only).

### When `manager=<specific>`

Skip detection. Use only the specified manager. Still run version check (Section 3).

## Section 8: Dry-Run Display and Gate 1 Confirmation

ALWAYS display the dry-run before any config writes. This is mandatory, not optional.

### Dry-Run Format

Display the following sections in order. Omit sections with no data.

```
================================================================
PRE-FLIGHT: minimum release age configuration
================================================================

Duration: <duration> (<source: default/user-specified>)
Project root: <resolved_root> (detected via <marker>)
Scope: <project|global>

-- Manager Version Check ------------------------------------------
| Manager | Installed | Required | Status |
|---------|-----------|----------|--------|
| <mgr> | <version> | <min_version> | OK / TOO_OLD / NOT_INSTALLED |

-- Frozen-Lockfile Usage Detected ---------------------------------
(only if scope=project and detections found)
| Manager | Command found | Location |
|---------|--------------|----------|
| <mgr> | <command> | <file>:<line> |

-- Configuration Changes ------------------------------------------
| Manager | Scope | File | Key | Current | New |
|---------|-------|------|-----|---------|-----|
| <mgr> | <scope> | <file> | <key> | <current_or_none> | <new_value> |

(unchanged) shown for keys where current == new

-- File Diffs -----------------------------------------------------
(unified diff per file, showing exact changes)

-- Exclusion Semantics --------------------------------------------
(only if --exclude provided)
| Manager | Exclusion key | Semantics | Notes |
|---------|--------------|-----------|-------|

-- Scope Overlap ---------------------------------------------------
(only if scope=project AND a global config also sets the same key for any manager)
| Manager | Global config | Global value | Note |
|---------|--------------|-------------|------|
| <mgr> | <global_file> | <global_value> | Project config will take precedence |

-- Warnings -------------------------------------------------------
(list all warnings accumulated during detection, version check, etc.)
- <warning message>

-- Warning-Only Managers ------------------------------------------
(only if warning-only managers detected)
| Manager | Status | Guidance |
|---------|--------|----------|
| Cargo | Not configurable (nightly-only) | Use cargo-deny for stable supply-chain controls |
| pip | No native age-gating | Migrate to uv |
| Go | No native age-gating | Use Go module proxy with age policies |

================================================================
```

### Gate 1: Confirmation

After displaying the dry-run, ask using AskUserQuestion:

- header: `Gate 1`
- question: "Apply the configuration changes shown above?" (include a parenthetical
  summary of what will be written vs skipped/unchanged)
- multiSelect: false
- options (select based on context — max 4 options, AskUserQuestion limit):

  **When `scope=project` AND `--harden` not set AND managers have TOO_OLD/NOT_INSTALLED:**
  - label: "Yes, and harden CLAUDE.md/AGENTS.md"
  - description: "Apply config + update CLAUDE.md/AGENTS.md with dependency security policy (Gates 2-4)"
  - label: "Yes"
  - description: "Apply the config changes only"
  - label: "Yes, and show update commands"
  - description: "Apply config + show install/update commands for skipped managers (age-verified)"
  - label: "No"
  - description: "Abort -- no changes will be written"

  **When `scope=project` AND `--harden` not set AND no skipped managers:**
  - label: "Yes, and harden CLAUDE.md/AGENTS.md"
  - description: "Apply config + update CLAUDE.md/AGENTS.md with dependency security policy (Gates 2-4)"
  - label: "Yes"
  - description: "Apply the config changes only"
  - label: "No"
  - description: "Abort -- no changes will be written"

  **When `--harden` already set (any scope):**
  - label: "Yes"
  - description: "Apply the config changes listed above"
  - label: "No"
  - description: "Abort -- no changes will be written"
  - label: "Yes, and show update commands" (only if managers have TOO_OLD/NOT_INSTALLED)
  - description: "Apply changes + show install/update commands for skipped managers (age-verified)"

  **When `scope=global` AND `--harden` not set:**
  - label: "Yes, and harden CLAUDE.md/AGENTS.md"
  - description: "Apply config + update CLAUDE.md/AGENTS.md with dependency security policy (Gates 2-4)"
  - label: "Yes"
  - description: "Apply the config changes listed above"
  - label: "No"
  - description: "Abort -- no changes will be written"
  - label: "Yes, and show update commands" (only if managers have TOO_OLD/NOT_INSTALLED)
  - description: "Apply changes + show install/update commands for skipped managers (age-verified)"

**Option handling:**
- "Yes": proceed with config writes (Section 4).
- "No": abort. Display "Aborted. No changes were written." and STOP.
- "Yes, and harden CLAUDE.md/AGENTS.md": proceed with config writes (Section 4),
  then set `--harden=true` and proceed to Gates 2-4 (Sections 11-13) after verification.
- "Yes, and show update commands": proceed with config writes (Section 4), then
  for each manager with status `TOO_OLD` or `NOT_INSTALLED`:
  1. Look up the manager's latest version that meets the configured minimum release
     age (per Section 3 "Manager Installation/Update Age Gate").
  2. Display the exact install/update guidance pinned to that age-verified version:
     - pnpm: `npm install -g pnpm@<version>`
     - yarn: `corepack enable && corepack prepare yarn@<version> --activate`
     - bun: manual install only -- show the exact Bun version to install and direct the user to the official Bun installation documentation. Do NOT use or display `curl | bash`.
     - npm: `npm install -g npm@<version>`
     - uv: `pip install uv==<version>` if `pip` is available; otherwise show manual installation guidance without `curl | sh`.
  3. After each command, note: "Version <version> published <N days> ago (meets <duration> age gate)."
  4. For commands that are safe to execute directly (`pnpm`, `yarn`, `npm`, and `uv` via `pip`), use AskUserQuestion:
     - header: `Prerequisite`
     - question: "Run the age-verified install/update command for <manager> now?"
     - options:
       - label: "Run now"
       - description: "Execute: <exact_command>"
       - label: "Show command only"
       - description: "Display the exact command without executing it"
       - label: "Skip"
       - description: "Do not install/update this manager now"
  5. If "Run now": execute the exact command.
  6. If "Show command only": display the exact command and continue.
  7. If "Skip": continue without executing anything.
  8. For Bun, always show manual guidance only and continue.

### Post-Apply Display

After successful config writes, display:

```
================================================================
APPLIED: minimum release age configuration
================================================================

| Manager | File | Status |
|---------|------|--------|
| <mgr> | <file> | Written / Unchanged / Skipped (<reason>) |

(frozen-lockfile warning if applicable -- see Section 6)
================================================================
```

## Section 8a: Post-Apply Verification

After every post-apply display, run verification for ALL managers that were configured
(not just hook-denied ones). This confirms the age gate is actually enforced at runtime.

**CRITICAL: Verification method depends on SCOPE. Project and global configs live in
different files and require different test strategies.**

### Scope-Aware Verification Strategy

| Scope | Verification method | Rationale |
|-------|-------------------|-----------|
| project | **Direct file read** (primary) + **functional test in project dir** (secondary) | Project config files are in-tree and readable. Temp dir tests would only hit global config. |
| global | **CLI config get** (pnpm, npm) + **functional test in temp dir** (bun, uv, yarn) | Global files may be hook-blocked. Temp dir inherits global config. |

### Project Scope Verification

**Primary: Direct file read.** The config file is in the project directory — read it
and confirm the expected key/value is present.

| Manager | File | Confirm |
|---------|------|---------|
| pnpm | `pnpm-workspace.yaml` | `minimumReleaseAge: <minutes>` exists |
| bun | `bunfig.toml` | `minimumReleaseAge = <seconds>` under `[install]` |
| npm | `.npmrc` | `min-release-age=<days>` line exists |
| uv | `pyproject.toml` | `exclude-newer = "<value>"` under `[tool.uv]` |
| yarn | `.yarnrc.yml` | `npmMinimalAgeGate: "<duration>"` exists |

**Secondary: Functional test in PROJECT directory.** To confirm runtime enforcement,
run the functional test FROM the project root (not a temp dir). This ensures the
project-level config is what gets tested.

**bun (project scope):**
```bash
cd <project_root>
# Create a minimal test subdir to avoid polluting project
mkdir -p .harden-test && cd .harden-test
echo '{"dependencies":{"<pkg>":"<version>"}}' > package.json
bun install --dry-run 2>&1
rc=$?
cd .. && rm -rf .harden-test
# PASS: output contains "blocked by minimum-release-age"
# FAIL: resolution succeeds — may be masked by global config
```

**uv (project scope):**
```bash
cd <project_root>
# uv reads pyproject.toml from project root
uv pip install --dry-run "<pkg>==<version>" 2>&1
# PASS: output indicates version excluded by exclude-newer
# FAIL: resolution succeeds
```

**yarn (project and global scope):**

NOTE: Yarn 4 does NOT support `install --dry-run`, and running `corepack yarn`
from a subdirectory of an existing Yarn project causes workspace resolution
errors. Yarn functional tests are therefore NOT viable. Rely on direct file
read verification only (the primary check).

For project scope: read `.yarnrc.yml` and confirm `npmMinimalAgeGate` is set.
For global scope: read `~/.yarnrc.yml` and confirm `npmMinimalAgeGate` is set.

Do NOT attempt to create a `.harden-test/` subdirectory for Yarn -- it will
fail due to Yarn's project boundary detection.

**WARNING**: If a global config also exists for the same manager, the functional test
may pass due to global config even if project config is broken. Always do the direct
file read check FIRST. If both global and project configs exist, note this in the report:
"Both project and global configs active — project config takes precedence."

### Global Scope Verification

**CLI-Based (pnpm, npm):**

| Manager | Command | Expected result |
|---------|---------|-----------------|
| pnpm | `pnpm config get minimum-release-age` | `<minutes>` (e.g., `10080`) |
| npm | `npm config get before --global` | Dynamic timestamp = `now - <duration>` (NOTE: `npm config get min-release-age` may return `null` — display quirk in npm 11.x, not a failure. The `before` value shifting with wall-clock time proves the age gate is active.) |
| yarn | Read `~/.yarnrc.yml` and confirm `npmMinimalAgeGate` key | `"<duration_string>"` (e.g., `"7d"`) |

**Functional Test in Temp Dir (bun, uv, yarn):**

For global scope, a temp directory correctly tests global config (no project config present).

**bun (global scope):**
```bash
tmpdir=$(mktemp -d)
cd "$tmpdir"
echo '{"dependencies":{"<pkg>":"<version>"}}' > package.json
bun install --dry-run 2>&1
rc=$?
rm -rf "$tmpdir"
# PASS: output contains "blocked by minimum-release-age"
# FAIL: resolution succeeds without error
```

**uv (global scope):**
```bash
tmpdir=$(mktemp -d)
cd "$tmpdir"
uv pip install --dry-run "<pkg>==<version>" 2>&1
rc=$?
rm -rf "$tmpdir"
# PASS: output indicates version excluded by exclude-newer
# FAIL: resolution succeeds without error
```

### Test Package Selection (applies to ALL functional tests)

Query the registry for a well-known, high-trust package with a version in the
**safe test window**: published `>= 1 day ago` (minimum) AND `< configured duration`
(so it SHOULD be blocked). Prefer versions `>= 3 days old` (sweet spot).

Candidate packages (tried in order, first match wins):

| Ecosystem | Candidates |
|-----------|------------|
| JS/TS (bun, yarn) | `typescript`, `eslint`, `express`, `npm` |
| Python (uv) | `ruff`, `black`, `requests`, `flask` |

Selection rules:
- Query: `npm view <pkg> time --json` (JS/TS) or PyPI JSON API (Python)
- Find the most recent version where `1 day <= age < configured duration`
- Prefer `age >= 3 days` (versions < 3 days old may be compromised)
- Reject versions `< 1 day old` (too fresh, security risk)
- If NO candidate has a version in the window: skip functional test with message
  "No suitable test package found in safe window. Direct file verification only."

### User Confirmation Gate

BEFORE running any functional test, present to user via AskUserQuestion:

- header: `Post-check`
- question: "Verify <manager> age gate with functional test?"
- options:
  - label: "Approve"
  - description: "<package>@<version> -- published <date> (<N days ago>). Well-known package. Test runs in <project dir | temp dir> with --dry-run. Expected: BLOCKED by <duration> age gate."
  - label: "Skip"
  - description: "S

…(truncated)
