Repository navigation
feat(cmvk): port production Cross-Model Verification Kernel from internal - #64
Conversation
- Add constitutional.py: ConstitutionalValidator with principles, evaluators, domain validation (904L) - Add benchmarks.py: Benchmark suite for single-model vs multi-model verification (478L) - Add profiles.py: Threshold profiles for carbon, financial, medical, strict, lenient (300L) - Upgrade verification.py: batch verification, explainability, audit trail (956L, was 588L) - Upgrade metrics.py: weighted distance functions (473L, was 347L) - Upgrade __init__.py: re-export new modules (218L) - Add comprehensive test suite from internal repo (26 test files) Closes #56 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
This PR ports the production-grade CMVK (Cross-Model Verification Kernel) from an internal repository to the public OSS toolkit. It adds new modules (constitutional validation, threshold profiles, benchmarks), upgrades existing verification and metrics modules from stdlib-only to numpy/scipy, and includes a comprehensive test suite.
Changes:
- Adds
constitutional.py(Constitutional AI validator with principles, evaluators, domain-specific validation),profiles.py(threshold profiles for carbon/financial/medical/strict/lenient domains), andbenchmarks.py(benchmark suite for single vs multi-model comparison) - Upgrades
verification.pyandmetrics.pyfrom Python stdlib to numpy/scipy, adding batch verification, explainability engine, audit trail integration, and configurable distance metrics - Adds 14+ test files across unit and integration test directories, plus updated
__init__.pyre-exports
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/cmvk/__init__.py |
Re-exports new modules (profiles, constitutional), updates version docs |
src/cmvk/verification.py |
Rewritten with numpy/scipy; adds batch verification, explainability, audit trail, profile integration |
src/cmvk/metrics.py |
Upgraded to numpy/scipy with enhanced metric implementations and documentation |
src/cmvk/profiles.py |
New: domain-specific threshold profiles (carbon, financial, medical, etc.) |
src/cmvk/constitutional.py |
New: Constitutional AI validator with principles, evaluators, and convenience functions |
src/cmvk/benchmarks.py |
New: Benchmark framework for single-model vs multi-model verification comparison |
tests/test_verification.py |
Tests for core verification functions |
tests/test_enhanced_features.py |
Tests for v0.2.0 features (metrics, profiles, batch, explainability, audit) |
tests/test_constitutional.py |
Tests for constitutional validator |
tests/conftest.py |
Shared pytest fixtures |
tests/unit/test_*.py |
Unit tests for kernel, CLI, agents, core types, humaneval loader, visualizer, trace logger, reproducibility |
tests/integration/test_*.py |
Integration tests for lateral thinking, prosecutor mode, anthropic verifier |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| from abc import ABC, abstractmethod | ||
| from dataclasses import dataclass, field | ||
| from enum import Enum | ||
| from typing import Any, Callable, Optional, Protocol, Sequence, Union |
There was a problem hiding this comment.
Unused imports: ABC and abstractmethod from abc are imported but never used. Additionally, Sequence and Union from typing are imported but never used. These should be removed to keep the import list clean.
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from typing import Any, Callable, Optional, Protocol, Sequence, Union | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from typing import Any, Callable, Optional, Protocol |
| Principle( | ||
| name="professional_tone", | ||
| description="Responses must maintain a professional tone", | ||
| severity="medium" | ||
| ), | ||
| Principle( | ||
| name="no_competitor_mentions", | ||
| description="Never mention competitor products by name", | ||
| severity="high" | ||
| ), | ||
| ] | ||
| ) |
There was a problem hiding this comment.
The docstring example uses severity="medium" (a plain string) instead of Severity.MEDIUM. While Severity inherits from str, the Principle dataclass declares severity: Severity = Severity.MEDIUM, so passing a raw string would not be valid unless the constructor performs coercion. This is misleading documentation that could cause user errors.
| # Group by category | ||
| by_category = {} | ||
| for r in results: | ||
| # Would need task info to group properly | ||
| pass | ||
|
|
||
| # Calculate latency | ||
| all_latencies = [] | ||
| for r in results: | ||
| for resp in r.responses: | ||
| all_latencies.append(resp.latency_ms) | ||
|
|
||
| avg_latency = np.mean(all_latencies) if all_latencies else 0.0 | ||
|
|
||
| return BenchmarkResults( | ||
| total_tasks=total, | ||
| correct=correct, | ||
| accuracy=correct / total if total > 0 else 0.0, | ||
| avg_latency_ms=avg_latency, | ||
| by_category={}, | ||
| by_difficulty={}, | ||
| timestamp=datetime.utcnow().isoformat(), | ||
| config=config or {} | ||
| ) |
There was a problem hiding this comment.
The by_category grouping is stubbed out — the loop body contains only pass, so the returned BenchmarkResults always has empty by_category and by_difficulty dicts. This makes the benchmark results incomplete for users who want to analyze performance by category. If this is intentional as a placeholder, it should be marked with a # TODO comment. Otherwise, the task ID should be mapped to task metadata so results can be properly grouped.
| # Use profile's default metric if none specified | ||
| if metric == "cosine" and profile.default_metric != "cosine": |
There was a problem hiding this comment.
The profile metric override logic silently replaces the user-specified metric only when the default is "cosine". If a user explicitly passes metric="cosine" together with threshold_profile="carbon" (which defaults to "euclidean"), their explicit choice gets overridden. The condition metric == "cosine" cannot distinguish between "user didn't specify a metric" and "user explicitly chose cosine." Consider using a sentinel value (e.g., metric: str | None = None) to differentiate between "no metric specified" and "explicitly chose cosine."
| # Use profile's default metric if none specified | |
| if metric == "cosine" and profile.default_metric != "cosine": | |
| # Use profile's default metric only when no metric was explicitly specified | |
| if metric is None: |
| avg_latency_ms=avg_latency, | ||
| by_category={}, | ||
| by_difficulty={}, | ||
| timestamp=datetime.utcnow().isoformat(), |
There was a problem hiding this comment.
datetime.utcnow() is deprecated since Python 3.12. The rest of the codebase (e.g., constitutional.py:372, audit.py:18) correctly uses datetime.now(timezone.utc) / datetime.now(UTC). This should be updated to datetime.now(timezone.utc).isoformat() for consistency and to avoid the deprecation warning.
| common = sum(1 for c in a if c in b) | ||
| total = len(a) + len(b) | ||
| similarity = (2 * common) / total if total > 0 else 0 | ||
| return 1.0 - similarity |
There was a problem hiding this comment.
_string_drift counts every character in a that also appears anywhere in b, which makes the metric insensitive to order and character frequency. For example, _string_drift("aaa", "a") would count common = 3 (all three 'a's match since 'a' in 'a' is true), giving similarity = 6/4 = 1.5 and a negative drift of -0.5. The similarity value can exceed 1.0 when a contains repeated characters that exist in b, producing invalid negative drift values. Consider using len(set(a) & set(b)) for set-based character overlap or a proper sequence similarity measure.
…afe output -- currently FAILING A3: 'response' output is currently undocumented as comment-only and there is no shell-safe variant; consumers wiring response into run: cause command injection. Test asserts a 'response-shell-safe' output and warning in action.yml. A6: neutralizeMentions only handled ASCII '@'. Attacker can bypass with U+FF20 fullwidth, µsoft#64;, @, or ZW-prefixed at-signs to mass-notify maintainers from AI-authored comments. A7: sanitizer leaves bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars, OSC-8 hyperlinks (ESC ] 8 ; ;), and skips NFKC normalization. Bidi flips rendered text, OSC-8 hides destinations behind plausible link text. All four tests fail on this commit because lib/sanitize.mjs does not yet exist and action.yml lacks the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com>
…output A6 (Medium): extend neutralizeMentions to catch HTML entities (`µsoft#64;`, `@` with optional leading zeros), Unicode fullwidth `U+FF20`, and zero-width-prefixed mentions (e.g. `@<ZWSP>user`). Previous regex only matched plain ASCII `@`. A7 (Medium): extend sanitizeForComment to strip bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars (U+200B-U+200D, U+2060, U+FEFF), and OSC-8 hyperlinks (`ESC]8;;...BEL` / `ESC]8;;...ESC\`). NFKC-normalize input first so fullwidth `U+FF1A` collapsing to `::` cannot bypass the workflow-command line filters. A3 (Medium): document that the `response` output is comment-safe only and add a separate `response-shell-safe` output (base64-encoded) for downstream steps that must pass the AI response into a shell `run:` block. Sanitized comment text can still contain backticks, dollar expansions, and command-substitution patterns; base64 reduces the payload to the fixed alphabet [A-Za-z0-9+/=]. Refactor the sanitization helpers out of the heredoc-embedded runner script into `.github/actions/ai-agent-runner/lib/sanitize.mjs`, which action.yml concatenates ahead of the runtime body and which the new `tests/ci/test_ai_agent_sanitize.py` imports directly via a Node child process. Tests cover the base64 round-trip, all alt-encoding mention forms, and bidi/zero-width/OSC-8/NFKC stripping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com>
…fe inputs (#2654) * ci: harden release automation and AI PR review against unsafe inputs Hardens CI / release / AI-review automation flagged by a recent security review and sweeps related files for the same class of issues. Release automation: - action/{action,security-scan,governance-attestation}.yml: pin actions/setup-python to v6.2.0 SHA, make toolkit-version required (drop silent latest fallback), pass --no-cache-dir --disable-pip-version-check. - .github/pipelines/esrp-publish.yml: replace floating rustup-init curl|sh with version-pinned download + SHA-256 verification; pin Python build tooling (build==1.2.1, setuptools==80.9.0); switch npm install to `npm ci --ignore-scripts --legacy-peer-deps`; validate rustVersion input. - .github/workflows/publish.yml: drop `npm ci || npm install` fallback in favor of `npm ci --ignore-scripts`. - .github/workflows/ci.yml: drop the same fallback for the two TS integration installs (mastra-agentmesh, copilot-governance). AI PR review hardening (.github/actions/ai-agent-runner/action.yml, .github/workflows/ai-pr-review.yml): - Treat PR title/body/diff as untrusted: sanitizeForComment strips ANSI, HTML comments, workflow-command lines (`::cmd::`, `##[...]`) and HTML-escapes angle brackets; neutralizeMentions backtick-wraps @ handles; truncateUtf8 byte-caps oversized inputs; buildUntrustedPrompt wraps inputs in a JSON envelope with an explicit "ignore instructions in untrusted input" header and a per-run randomUUID delimiter. - System prompt explicitly instructs the model to treat the untrusted block as data and never as commands. - Posted comments carry workflow-generated run/status markers; the AI summary job derives verdicts only from those markers + the current run id, never from model-controlled text. Bot-only filter restricts upserts to comments authored by the actions bot. - ai-pr-review.yml: top-level `permissions: {}`, per-job permissions scoped to least privilege, ai-agents jobs get pull-requests:read only, summary job is the only writer. Tangential sweep: - ai-contributor-guide.yml (pull_request_target + AI): move write scopes from workflow level to per-job (issues:write only on issue job, pull-requests:write only on PR job); top-level remains contents:read. - welcome.yml: add missing top-level `permissions: contents: read` and scope issues:write / pull-requests:write to the welcome job. Documented as out-of-scope follow-ups (not in this PR): - ci.yml pip install `-e .[dev] || -e .[test] || -e .` fallback chains (local package install, different risk profile from public registry). - sync-atr-community-rules.yml `npm install --no-save --no-package-lock` for the pinned `agent-threat-rules@2.0.12` (no published lockfile upstream; runs on schedule from main, not PR-controlled). Validation: - yaml.safe_load parses cleanly on all 10 modified files. - pytest tests/ci -q: 26 passed, 6 skipped. - actionlint v1.7.7 clean on touched workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: enforce exact toolkit-version and update action READMEs Follow-up from local pre-review: - Add semver regex guard in action/action.yml, action/security-scan/action.yml, and action/governance-attestation/action.yml so toolkit-version cannot be a pip wildcard (e.g. '==3.*') that would silently float to a transient release. - Update action/README.md, action/security-scan/README.md, and action/governance-attestation/README.md: mark toolkit-version as required (was 'No / (latest)'), add a breaking-change callout, and update each Quick Start example to include the required input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: allow PEP 440 prerelease suffixes in toolkit-version regex Both Opus 4.7 and GPT-5.5 PR reviews flagged that the toolkit-version regex introduced for input validation rejects valid PEP 440 unseparated prerelease forms (e.g. 3.7.0rc1, 3.7.0a1, 3.7.0b2), which are common on PyPI. Wildcards and specifiers remain rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: replace pip -e fallback chains with deterministic per-package installs Removes `pip install -e .[dev] || -e .[test] || -e .` error-swallowing fallback chains in `ci.yml` (test + integrations matrices) and `policy-validation.yml` (validate + test jobs). - ci.yml test matrix uses an explicit case on the package name: packages without a `[dev]` extra (agent-compliance, agent-runtime, agent-mcp-governance) install plain `.`; the rest install `.[dev]`. Unknown packages hard-fail so a new matrix entry without a mapping update is caught. - ci.yml integrations matrix installs `.[dev]` directly -- all 21 packages declare the extra (audited 2026-05). - policy-validation.yml agent-os installs use `.[dev]` directly. Job behavior is unchanged for the existing matrix; install failures now surface explicitly instead of being masked into a less-complete install. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: verify ATR npm tarball SHA-512 before install `sync-atr-community-rules.yml` previously ran `npm install --no-save --no-package-lock agent-threat-rules@2.0.12` with no integrity check, so a malicious republish at the same version would silently flow into the auto-generated policy PR. New flow: 1. Resolve the tarball URL from the registry metadata for the pinned version. 2. Download the tarball. 3. Compute SHA-512 and compare against the committed `ATR_INTEGRITY` value (sha512-<base64>, same format npm uses internally). 4. Install from the local verified tarball. 5. Sanity-check the installed package version matches the pinned version. Any mismatch in step 3 aborts the workflow before sync runs. The pinned integrity value must be updated together with version bumps; the comment documents how to fetch a fresh value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: scope write permissions per-job in single-job utility workflows Brings five auxiliary workflows in line with the convention already used by ci.yml / publish.yml / ai-pr-review.yml: top-level `contents: read` only, with write/read scopes attached to the single job that needs them. Files: - contributor-check.yml: issues:write + pull-requests:write moved to `check` job (required by .github/actions/contributor-check which posts comments/labels). - pr-size.yml: pull-requests:write moved to `size-label` job (codelytv/pr-size-labeler applies size/* labels). - labeler.yml: pull-requests:write moved to `label` job (actions/labeler). - pr-title-check.yml: pull-requests:read moved to `semantic-title` job (amannn/action-semantic-pull-request reads PR title/body); contents:read added at top. - require-maintainer-approval.yml: pull-requests:read moved to `check-approval` job (github-script calls pulls.listReviews). No behavior change today (single-job workflows have identical effective permissions either way) but eliminates surprise if a second job is added and inherits broader perms than intended. Per-job comments document which API call each permission unlocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A1+A9 ATR npm install runs lifecycle scripts and uses fixed tarball path -- currently FAILING A1: every npm install line in sync-atr-community-rules.yml must include --ignore-scripts. SHA-512 only verifies bytes-as-published; a compromised publisher re-publishing at the same version still executes lifecycle scripts in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. A9: /tmp/atr.tgz is predictable per runner; switch to mktemp with atr.XXXXXX.tgz template as defense-in-depth on shared runners. Both tests fail on this commit; the next commit makes them pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): block npm lifecycle scripts and unpredictable tarball path for ATR sync A1 (High): npm install verified the ATR tarball bytes via SHA-512 but still ran the package's preinstall/install/postinstall lifecycle scripts. A malicious republish that flips the upstream integrity hash would be rejected, but a compromised publisher who registers a *new* SHA still got arbitrary code execution in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. Add --ignore-scripts so the sync only reads files from node_modules/agent-threat-rules/rules/, which is all the workflow actually needs. A9 (Low): switch /tmp/atr.tgz to mktemp -t atr.XXXXXX.tgz to give the tarball an unpredictable per-run path. Cheap defense-in-depth against TOCTOU on shared (e.g. self-hosted) runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A2 toolkit-version regex accepts PEP 440 post/dev/local-version -- currently FAILING The original ([.+-][A-Za-z0-9._+-]+)? alternation allowed 3.7.0.post1, 3.7.0.dev1, and 3.7.0+local through validation. Pip resolves those to artifacts other than the canonical release, so the upstream check did not guarantee that the canonical version was installed. Test asserts the tightened regex literal across all 3 actions and that each README documents an 'Accepted version syntax' section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): tighten toolkit-version regex to reject post, dev, and local-version forms A2 (Medium): the previous regex appended `([.+-][A-Za-z0-9._+-]+)?` to the release/pre-release form, which accepts `3.7.0.post1`, `3.7.0.dev0`, `3.7.0+anything`, and `3.7.0-anything`. Those are all valid PEP 440 / pip syntax and let an attacker who can influence the toolkit-version input (e.g. via a downstream workflow that interpolates a user-controlled value) install a yanked or non-public distribution alongside the apparent release. Drop that optional group. Accept only X.Y.Z and X.Y.ZaN | X.Y.ZbN | X.Y.ZrcN. Update the three composite action READMEs with an Accepted version syntax section that documents what is accepted and what is rejected, plus a note in the breaking-change banner pointing to that section. Also reminds consumers to pin the action to `@v3` (the existing major-tag) so the new toolkit-version requirement does not break their pipeline at action-update time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A3+A6+A7 AI agent runner sanitizer + shell-safe output -- currently FAILING A3: 'response' output is currently undocumented as comment-only and there is no shell-safe variant; consumers wiring response into run: cause command injection. Test asserts a 'response-shell-safe' output and warning in action.yml. A6: neutralizeMentions only handled ASCII '@'. Attacker can bypass with U+FF20 fullwidth, @, @, or ZW-prefixed at-signs to mass-notify maintainers from AI-authored comments. A7: sanitizer leaves bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars, OSC-8 hyperlinks (ESC ] 8 ; ;), and skips NFKC normalization. Bidi flips rendered text, OSC-8 hides destinations behind plausible link text. All four tests fail on this commit because lib/sanitize.mjs does not yet exist and action.yml lacks the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): harden AI agent runner sanitization and add shell-safe output A6 (Medium): extend neutralizeMentions to catch HTML entities (`@`, `@` with optional leading zeros), Unicode fullwidth `U+FF20`, and zero-width-prefixed mentions (e.g. `@<ZWSP>user`). Previous regex only matched plain ASCII `@`. A7 (Medium): extend sanitizeForComment to strip bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars (U+200B-U+200D, U+2060, U+FEFF), and OSC-8 hyperlinks (`ESC]8;;...BEL` / `ESC]8;;...ESC\`). NFKC-normalize input first so fullwidth `U+FF1A` collapsing to `::` cannot bypass the workflow-command line filters. A3 (Medium): document that the `response` output is comment-safe only and add a separate `response-shell-safe` output (base64-encoded) for downstream steps that must pass the AI response into a shell `run:` block. Sanitized comment text can still contain backticks, dollar expansions, and command-substitution patterns; base64 reduces the payload to the fixed alphabet [A-Za-z0-9+/=]. Refactor the sanitization helpers out of the heredoc-embedded runner script into `.github/actions/ai-agent-runner/lib/sanitize.mjs`, which action.yml concatenates ahead of the runtime body and which the new `tests/ci/test_ai_agent_sanitize.py` imports directly via a Node child process. Tests cover the base64 round-trip, all alt-encoding mention forms, and bidi/zero-width/OSC-8/NFKC stripping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A8 ai-pr-review RUN_MARKER varies across re-runs -- currently FAILING RUN_MARKER embeds GITHUB_RUN_ATTEMPT so a maintainer triggering a workflow re-run produces a new marker. The aggregator filter requires the marker to match prior comments, so AI summaries silently break across re-runs and the new run cannot find the prior agent comment to update or correlate with. Test parses ai-pr-review.yml, finds the RUN_MARKER assignment, and asserts GITHUB_RUN_ATTEMPT is absent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): make AI summary aggregator marker stable across re-runs A8 (Low): RUN_MARKER previously included GITHUB_RUN_ATTEMPT, so on workflow re-run the aggregator could not find comments from a previous attempt — leaving orphaned per-agent comments tagged with a marker the new attempt would never query. Drop the attempt suffix so RUN_ID alone identifies all comments from this logical workflow run, regardless of attempt count. Add an inline comment explaining why so a future refactor does not silently re-introduce the suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A4+A5 ESRP build tools lack hash pinning and rustVersion validation lacks comment -- currently FAILING A4: ESRP pip install pinned versions but not hashes and did not pass --no-deps; pip resolved transitives from PyPI at run time, giving anyone who could swap a pinned transitive code execution inside the release pipeline. A5: rustVersion regex validation is template-expanded at queue time but enforced at runtime; without a comment the operator cannot tell that ADO queue-time parameter ACLs are the primary defense and the regex is the secondary one. Tests fail because no release-tools.txt lockfile exists, the install step does not request --require-hashes, and no explanatory comment surrounds the rustVersion block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): hash-pin ESRP build tools and document rustVersion validation order A4 (Medium): the PyPI build stage previously installed `build==1.2.1 setuptools==80.9.0` with no integrity verification and no transitive pinning, so a compromised mirror or a yanked-and-re-uploaded wheel could ship a backdoored `build` or `setuptools` straight into our signing pipeline. Generate `release-tools.txt` via pip-tools with `--generate-hashes --allow-unsafe` and switch the ESRP step to `--require-hashes --no-deps -r release-tools.txt`. Commit both the input (`release-tools.in`) and the resolved lockfile. Header in the lockfile documents the regeneration command. A5 (Low): add an inline comment near the rustVersion validation in esrp-publish.yml explaining the template-expansion-vs-runtime-regex order — the regex is defense-in-depth for a future refactor that promotes rustVersion to a runtime variable, and the primary defense remains the ADO queue-time parameter ACL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A10 AI agent runner accepts arbitrary output-mode -- currently FAILING Without an allowlist + permission-requirement map, a future caller setting output-mode to a value whose required permissions are not granted produces silent failures instead of fail-fast. Test asserts both ALLOWED_OUTPUT_MODES and OUTPUT_MODE_REQUIRED_PERMS are present in the runner script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): startup-check output-mode in AI agent runner A10 (Low): when a caller sets `output-mode: pr-review` but only grants `pull-requests: read`, the action previously failed mid-execution after burning LLM tokens. Worse, a future caller-input bug that lets an untrusted value reach `output-mode` could silently steer postResults into an unintended code path. Add an allow-list check at the top of main() that rejects unknown modes before any network I/O. Document the workflow permissions each output mode requires so a caller can grant the minimum. Keep ai-pr-review.yml on `pull-requests: read` (the existing mode is `pr-comment` which falls back to `issues: write`); upgrading the permission is the caller's decision, not the action's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A11 missing BREAKING_CHANGES.md for toolkit-version requirement -- currently FAILING Making toolkit-version required is a breaking change for downstream consumers; they need a migration document to pin to @V3 and start supplying the input. Test asserts BREAKING_CHANGES.md exists at repo root and labels the change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): document toolkit-version breaking change in BREAKING_CHANGES.md A11: `toolkit-version` is now required on the three composite actions, with a strict regex that rejects post / dev / local-version forms. Add a top-level BREAKING_CHANGES.md entry documenting the migration path and recommending consumers pin to `@v3` (the existing major-tag) so the new requirement does not break their pipeline at action-update time. The actual `@v3` tag cut is a release-time decision and is intentionally not performed in this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for ci-test.txt install swallows failures via || true -- currently FAILING Two install steps in ci.yml used 'pip install ... --require-hashes -r ci-test.txt 2>/dev/null || true', which swallowed every pip failure including hash mismatches, missing files, and resolver errors. CI then proceeded as if install had succeeded, defeating the purpose of --require-hashes. Test parses ci.yml, finds every line referencing ci-test.txt, and asserts no '|| true' swallow is adjacent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): drop || true error swallow on hash-pinned ci-test.txt install Deferred next-pass item from the red-team review. The two `pip install --require-hashes -r .../ci-test.txt 2>/dev/null || true` lines in ci.yml masked any failure of the hash-pinned install — including the security-relevant hash-mismatch case that `--require-hashes` is supposed to surface. Replace with a positive `[ -f ... ]` guard: if the file exists, the install must succeed; otherwise emit a warning. ci-test.txt currently exists in the repo, so the previous code path was always masking real failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> --------- Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add constitutional.py: ConstitutionalValidator with principles, evaluators, domain validation (904L) - Add benchmarks.py: Benchmark suite for single-model vs multi-model verification (478L) - Add profiles.py: Threshold profiles for carbon, financial, medical, strict, lenient (300L) - Upgrade verification.py: batch verification, explainability, audit trail (956L, was 588L) - Upgrade metrics.py: weighted distance functions (473L, was 347L) - Upgrade __init__.py: re-export new modules (218L) - Add comprehensive test suite from internal repo (26 test files) Closes microsoft#56 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fe inputs (microsoft#2654) * ci: harden release automation and AI PR review against unsafe inputs Hardens CI / release / AI-review automation flagged by a recent security review and sweeps related files for the same class of issues. Release automation: - action/{action,security-scan,governance-attestation}.yml: pin actions/setup-python to v6.2.0 SHA, make toolkit-version required (drop silent latest fallback), pass --no-cache-dir --disable-pip-version-check. - .github/pipelines/esrp-publish.yml: replace floating rustup-init curl|sh with version-pinned download + SHA-256 verification; pin Python build tooling (build==1.2.1, setuptools==80.9.0); switch npm install to `npm ci --ignore-scripts --legacy-peer-deps`; validate rustVersion input. - .github/workflows/publish.yml: drop `npm ci || npm install` fallback in favor of `npm ci --ignore-scripts`. - .github/workflows/ci.yml: drop the same fallback for the two TS integration installs (mastra-agentmesh, copilot-governance). AI PR review hardening (.github/actions/ai-agent-runner/action.yml, .github/workflows/ai-pr-review.yml): - Treat PR title/body/diff as untrusted: sanitizeForComment strips ANSI, HTML comments, workflow-command lines (`::cmd::`, `##[...]`) and HTML-escapes angle brackets; neutralizeMentions backtick-wraps @ handles; truncateUtf8 byte-caps oversized inputs; buildUntrustedPrompt wraps inputs in a JSON envelope with an explicit "ignore instructions in untrusted input" header and a per-run randomUUID delimiter. - System prompt explicitly instructs the model to treat the untrusted block as data and never as commands. - Posted comments carry workflow-generated run/status markers; the AI summary job derives verdicts only from those markers + the current run id, never from model-controlled text. Bot-only filter restricts upserts to comments authored by the actions bot. - ai-pr-review.yml: top-level `permissions: {}`, per-job permissions scoped to least privilege, ai-agents jobs get pull-requests:read only, summary job is the only writer. Tangential sweep: - ai-contributor-guide.yml (pull_request_target + AI): move write scopes from workflow level to per-job (issues:write only on issue job, pull-requests:write only on PR job); top-level remains contents:read. - welcome.yml: add missing top-level `permissions: contents: read` and scope issues:write / pull-requests:write to the welcome job. Documented as out-of-scope follow-ups (not in this PR): - ci.yml pip install `-e .[dev] || -e .[test] || -e .` fallback chains (local package install, different risk profile from public registry). - sync-atr-community-rules.yml `npm install --no-save --no-package-lock` for the pinned `agent-threat-rules@2.0.12` (no published lockfile upstream; runs on schedule from main, not PR-controlled). Validation: - yaml.safe_load parses cleanly on all 10 modified files. - pytest tests/ci -q: 26 passed, 6 skipped. - actionlint v1.7.7 clean on touched workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: enforce exact toolkit-version and update action READMEs Follow-up from local pre-review: - Add semver regex guard in action/action.yml, action/security-scan/action.yml, and action/governance-attestation/action.yml so toolkit-version cannot be a pip wildcard (e.g. '==3.*') that would silently float to a transient release. - Update action/README.md, action/security-scan/README.md, and action/governance-attestation/README.md: mark toolkit-version as required (was 'No / (latest)'), add a breaking-change callout, and update each Quick Start example to include the required input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: allow PEP 440 prerelease suffixes in toolkit-version regex Both Opus 4.7 and GPT-5.5 PR reviews flagged that the toolkit-version regex introduced for input validation rejects valid PEP 440 unseparated prerelease forms (e.g. 3.7.0rc1, 3.7.0a1, 3.7.0b2), which are common on PyPI. Wildcards and specifiers remain rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: replace pip -e fallback chains with deterministic per-package installs Removes `pip install -e .[dev] || -e .[test] || -e .` error-swallowing fallback chains in `ci.yml` (test + integrations matrices) and `policy-validation.yml` (validate + test jobs). - ci.yml test matrix uses an explicit case on the package name: packages without a `[dev]` extra (agent-compliance, agent-runtime, agent-mcp-governance) install plain `.`; the rest install `.[dev]`. Unknown packages hard-fail so a new matrix entry without a mapping update is caught. - ci.yml integrations matrix installs `.[dev]` directly -- all 21 packages declare the extra (audited 2026-05). - policy-validation.yml agent-os installs use `.[dev]` directly. Job behavior is unchanged for the existing matrix; install failures now surface explicitly instead of being masked into a less-complete install. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: verify ATR npm tarball SHA-512 before install `sync-atr-community-rules.yml` previously ran `npm install --no-save --no-package-lock agent-threat-rules@2.0.12` with no integrity check, so a malicious republish at the same version would silently flow into the auto-generated policy PR. New flow: 1. Resolve the tarball URL from the registry metadata for the pinned version. 2. Download the tarball. 3. Compute SHA-512 and compare against the committed `ATR_INTEGRITY` value (sha512-<base64>, same format npm uses internally). 4. Install from the local verified tarball. 5. Sanity-check the installed package version matches the pinned version. Any mismatch in step 3 aborts the workflow before sync runs. The pinned integrity value must be updated together with version bumps; the comment documents how to fetch a fresh value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: scope write permissions per-job in single-job utility workflows Brings five auxiliary workflows in line with the convention already used by ci.yml / publish.yml / ai-pr-review.yml: top-level `contents: read` only, with write/read scopes attached to the single job that needs them. Files: - contributor-check.yml: issues:write + pull-requests:write moved to `check` job (required by .github/actions/contributor-check which posts comments/labels). - pr-size.yml: pull-requests:write moved to `size-label` job (codelytv/pr-size-labeler applies size/* labels). - labeler.yml: pull-requests:write moved to `label` job (actions/labeler). - pr-title-check.yml: pull-requests:read moved to `semantic-title` job (amannn/action-semantic-pull-request reads PR title/body); contents:read added at top. - require-maintainer-approval.yml: pull-requests:read moved to `check-approval` job (github-script calls pulls.listReviews). No behavior change today (single-job workflows have identical effective permissions either way) but eliminates surprise if a second job is added and inherits broader perms than intended. Per-job comments document which API call each permission unlocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A1+A9 ATR npm install runs lifecycle scripts and uses fixed tarball path -- currently FAILING A1: every npm install line in sync-atr-community-rules.yml must include --ignore-scripts. SHA-512 only verifies bytes-as-published; a compromised publisher re-publishing at the same version still executes lifecycle scripts in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. A9: /tmp/atr.tgz is predictable per runner; switch to mktemp with atr.XXXXXX.tgz template as defense-in-depth on shared runners. Both tests fail on this commit; the next commit makes them pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): block npm lifecycle scripts and unpredictable tarball path for ATR sync A1 (High): npm install verified the ATR tarball bytes via SHA-512 but still ran the package's preinstall/install/postinstall lifecycle scripts. A malicious republish that flips the upstream integrity hash would be rejected, but a compromised publisher who registers a *new* SHA still got arbitrary code execution in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. Add --ignore-scripts so the sync only reads files from node_modules/agent-threat-rules/rules/, which is all the workflow actually needs. A9 (Low): switch /tmp/atr.tgz to mktemp -t atr.XXXXXX.tgz to give the tarball an unpredictable per-run path. Cheap defense-in-depth against TOCTOU on shared (e.g. self-hosted) runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A2 toolkit-version regex accepts PEP 440 post/dev/local-version -- currently FAILING The original ([.+-][A-Za-z0-9._+-]+)? alternation allowed 3.7.0.post1, 3.7.0.dev1, and 3.7.0+local through validation. Pip resolves those to artifacts other than the canonical release, so the upstream check did not guarantee that the canonical version was installed. Test asserts the tightened regex literal across all 3 actions and that each README documents an 'Accepted version syntax' section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): tighten toolkit-version regex to reject post, dev, and local-version forms A2 (Medium): the previous regex appended `([.+-][A-Za-z0-9._+-]+)?` to the release/pre-release form, which accepts `3.7.0.post1`, `3.7.0.dev0`, `3.7.0+anything`, and `3.7.0-anything`. Those are all valid PEP 440 / pip syntax and let an attacker who can influence the toolkit-version input (e.g. via a downstream workflow that interpolates a user-controlled value) install a yanked or non-public distribution alongside the apparent release. Drop that optional group. Accept only X.Y.Z and X.Y.ZaN | X.Y.ZbN | X.Y.ZrcN. Update the three composite action READMEs with an Accepted version syntax section that documents what is accepted and what is rejected, plus a note in the breaking-change banner pointing to that section. Also reminds consumers to pin the action to `@v3` (the existing major-tag) so the new toolkit-version requirement does not break their pipeline at action-update time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A3+A6+A7 AI agent runner sanitizer + shell-safe output -- currently FAILING A3: 'response' output is currently undocumented as comment-only and there is no shell-safe variant; consumers wiring response into run: cause command injection. Test asserts a 'response-shell-safe' output and warning in action.yml. A6: neutralizeMentions only handled ASCII '@'. Attacker can bypass with U+FF20 fullwidth, µsoft#64;, @, or ZW-prefixed at-signs to mass-notify maintainers from AI-authored comments. A7: sanitizer leaves bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars, OSC-8 hyperlinks (ESC ] 8 ; ;), and skips NFKC normalization. Bidi flips rendered text, OSC-8 hides destinations behind plausible link text. All four tests fail on this commit because lib/sanitize.mjs does not yet exist and action.yml lacks the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): harden AI agent runner sanitization and add shell-safe output A6 (Medium): extend neutralizeMentions to catch HTML entities (`µsoft#64;`, `@` with optional leading zeros), Unicode fullwidth `U+FF20`, and zero-width-prefixed mentions (e.g. `@<ZWSP>user`). Previous regex only matched plain ASCII `@`. A7 (Medium): extend sanitizeForComment to strip bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars (U+200B-U+200D, U+2060, U+FEFF), and OSC-8 hyperlinks (`ESC]8;;...BEL` / `ESC]8;;...ESC\`). NFKC-normalize input first so fullwidth `U+FF1A` collapsing to `::` cannot bypass the workflow-command line filters. A3 (Medium): document that the `response` output is comment-safe only and add a separate `response-shell-safe` output (base64-encoded) for downstream steps that must pass the AI response into a shell `run:` block. Sanitized comment text can still contain backticks, dollar expansions, and command-substitution patterns; base64 reduces the payload to the fixed alphabet [A-Za-z0-9+/=]. Refactor the sanitization helpers out of the heredoc-embedded runner script into `.github/actions/ai-agent-runner/lib/sanitize.mjs`, which action.yml concatenates ahead of the runtime body and which the new `tests/ci/test_ai_agent_sanitize.py` imports directly via a Node child process. Tests cover the base64 round-trip, all alt-encoding mention forms, and bidi/zero-width/OSC-8/NFKC stripping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A8 ai-pr-review RUN_MARKER varies across re-runs -- currently FAILING RUN_MARKER embeds GITHUB_RUN_ATTEMPT so a maintainer triggering a workflow re-run produces a new marker. The aggregator filter requires the marker to match prior comments, so AI summaries silently break across re-runs and the new run cannot find the prior agent comment to update or correlate with. Test parses ai-pr-review.yml, finds the RUN_MARKER assignment, and asserts GITHUB_RUN_ATTEMPT is absent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): make AI summary aggregator marker stable across re-runs A8 (Low): RUN_MARKER previously included GITHUB_RUN_ATTEMPT, so on workflow re-run the aggregator could not find comments from a previous attempt — leaving orphaned per-agent comments tagged with a marker the new attempt would never query. Drop the attempt suffix so RUN_ID alone identifies all comments from this logical workflow run, regardless of attempt count. Add an inline comment explaining why so a future refactor does not silently re-introduce the suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A4+A5 ESRP build tools lack hash pinning and rustVersion validation lacks comment -- currently FAILING A4: ESRP pip install pinned versions but not hashes and did not pass --no-deps; pip resolved transitives from PyPI at run time, giving anyone who could swap a pinned transitive code execution inside the release pipeline. A5: rustVersion regex validation is template-expanded at queue time but enforced at runtime; without a comment the operator cannot tell that ADO queue-time parameter ACLs are the primary defense and the regex is the secondary one. Tests fail because no release-tools.txt lockfile exists, the install step does not request --require-hashes, and no explanatory comment surrounds the rustVersion block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): hash-pin ESRP build tools and document rustVersion validation order A4 (Medium): the PyPI build stage previously installed `build==1.2.1 setuptools==80.9.0` with no integrity verification and no transitive pinning, so a compromised mirror or a yanked-and-re-uploaded wheel could ship a backdoored `build` or `setuptools` straight into our signing pipeline. Generate `release-tools.txt` via pip-tools with `--generate-hashes --allow-unsafe` and switch the ESRP step to `--require-hashes --no-deps -r release-tools.txt`. Commit both the input (`release-tools.in`) and the resolved lockfile. Header in the lockfile documents the regeneration command. A5 (Low): add an inline comment near the rustVersion validation in esrp-publish.yml explaining the template-expansion-vs-runtime-regex order — the regex is defense-in-depth for a future refactor that promotes rustVersion to a runtime variable, and the primary defense remains the ADO queue-time parameter ACL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A10 AI agent runner accepts arbitrary output-mode -- currently FAILING Without an allowlist + permission-requirement map, a future caller setting output-mode to a value whose required permissions are not granted produces silent failures instead of fail-fast. Test asserts both ALLOWED_OUTPUT_MODES and OUTPUT_MODE_REQUIRED_PERMS are present in the runner script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): startup-check output-mode in AI agent runner A10 (Low): when a caller sets `output-mode: pr-review` but only grants `pull-requests: read`, the action previously failed mid-execution after burning LLM tokens. Worse, a future caller-input bug that lets an untrusted value reach `output-mode` could silently steer postResults into an unintended code path. Add an allow-list check at the top of main() that rejects unknown modes before any network I/O. Document the workflow permissions each output mode requires so a caller can grant the minimum. Keep ai-pr-review.yml on `pull-requests: read` (the existing mode is `pr-comment` which falls back to `issues: write`); upgrading the permission is the caller's decision, not the action's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A11 missing BREAKING_CHANGES.md for toolkit-version requirement -- currently FAILING Making toolkit-version required is a breaking change for downstream consumers; they need a migration document to pin to @V3 and start supplying the input. Test asserts BREAKING_CHANGES.md exists at repo root and labels the change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): document toolkit-version breaking change in BREAKING_CHANGES.md A11: `toolkit-version` is now required on the three composite actions, with a strict regex that rejects post / dev / local-version forms. Add a top-level BREAKING_CHANGES.md entry documenting the migration path and recommending consumers pin to `@v3` (the existing major-tag) so the new requirement does not break their pipeline at action-update time. The actual `@v3` tag cut is a release-time decision and is intentionally not performed in this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for ci-test.txt install swallows failures via || true -- currently FAILING Two install steps in ci.yml used 'pip install ... --require-hashes -r ci-test.txt 2>/dev/null || true', which swallowed every pip failure including hash mismatches, missing files, and resolver errors. CI then proceeded as if install had succeeded, defeating the purpose of --require-hashes. Test parses ci.yml, finds every line referencing ci-test.txt, and asserts no '|| true' swallow is adjacent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): drop || true error swallow on hash-pinned ci-test.txt install Deferred next-pass item from the red-team review. The two `pip install --require-hashes -r .../ci-test.txt 2>/dev/null || true` lines in ci.yml masked any failure of the hash-pinned install — including the security-relevant hash-mismatch case that `--require-hashes` is supposed to surface. Replace with a positive `[ -f ... ]` guard: if the file exists, the install must succeed; otherwise emit a warning. ci-test.txt currently exists in the repo, so the previous code path was always masking real failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> --------- Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fe inputs (microsoft#2654) * ci: harden release automation and AI PR review against unsafe inputs Hardens CI / release / AI-review automation flagged by a recent security review and sweeps related files for the same class of issues. Release automation: - action/{action,security-scan,governance-attestation}.yml: pin actions/setup-python to v6.2.0 SHA, make toolkit-version required (drop silent latest fallback), pass --no-cache-dir --disable-pip-version-check. - .github/pipelines/esrp-publish.yml: replace floating rustup-init curl|sh with version-pinned download + SHA-256 verification; pin Python build tooling (build==1.2.1, setuptools==80.9.0); switch npm install to `npm ci --ignore-scripts --legacy-peer-deps`; validate rustVersion input. - .github/workflows/publish.yml: drop `npm ci || npm install` fallback in favor of `npm ci --ignore-scripts`. - .github/workflows/ci.yml: drop the same fallback for the two TS integration installs (mastra-agentmesh, copilot-governance). AI PR review hardening (.github/actions/ai-agent-runner/action.yml, .github/workflows/ai-pr-review.yml): - Treat PR title/body/diff as untrusted: sanitizeForComment strips ANSI, HTML comments, workflow-command lines (`::cmd::`, `##[...]`) and HTML-escapes angle brackets; neutralizeMentions backtick-wraps @ handles; truncateUtf8 byte-caps oversized inputs; buildUntrustedPrompt wraps inputs in a JSON envelope with an explicit "ignore instructions in untrusted input" header and a per-run randomUUID delimiter. - System prompt explicitly instructs the model to treat the untrusted block as data and never as commands. - Posted comments carry workflow-generated run/status markers; the AI summary job derives verdicts only from those markers + the current run id, never from model-controlled text. Bot-only filter restricts upserts to comments authored by the actions bot. - ai-pr-review.yml: top-level `permissions: {}`, per-job permissions scoped to least privilege, ai-agents jobs get pull-requests:read only, summary job is the only writer. Tangential sweep: - ai-contributor-guide.yml (pull_request_target + AI): move write scopes from workflow level to per-job (issues:write only on issue job, pull-requests:write only on PR job); top-level remains contents:read. - welcome.yml: add missing top-level `permissions: contents: read` and scope issues:write / pull-requests:write to the welcome job. Documented as out-of-scope follow-ups (not in this PR): - ci.yml pip install `-e .[dev] || -e .[test] || -e .` fallback chains (local package install, different risk profile from public registry). - sync-atr-community-rules.yml `npm install --no-save --no-package-lock` for the pinned `agent-threat-rules@2.0.12` (no published lockfile upstream; runs on schedule from main, not PR-controlled). Validation: - yaml.safe_load parses cleanly on all 10 modified files. - pytest tests/ci -q: 26 passed, 6 skipped. - actionlint v1.7.7 clean on touched workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: enforce exact toolkit-version and update action READMEs Follow-up from local pre-review: - Add semver regex guard in action/action.yml, action/security-scan/action.yml, and action/governance-attestation/action.yml so toolkit-version cannot be a pip wildcard (e.g. '==3.*') that would silently float to a transient release. - Update action/README.md, action/security-scan/README.md, and action/governance-attestation/README.md: mark toolkit-version as required (was 'No / (latest)'), add a breaking-change callout, and update each Quick Start example to include the required input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: allow PEP 440 prerelease suffixes in toolkit-version regex Both Opus 4.7 and GPT-5.5 PR reviews flagged that the toolkit-version regex introduced for input validation rejects valid PEP 440 unseparated prerelease forms (e.g. 3.7.0rc1, 3.7.0a1, 3.7.0b2), which are common on PyPI. Wildcards and specifiers remain rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: replace pip -e fallback chains with deterministic per-package installs Removes `pip install -e .[dev] || -e .[test] || -e .` error-swallowing fallback chains in `ci.yml` (test + integrations matrices) and `policy-validation.yml` (validate + test jobs). - ci.yml test matrix uses an explicit case on the package name: packages without a `[dev]` extra (agent-compliance, agent-runtime, agent-mcp-governance) install plain `.`; the rest install `.[dev]`. Unknown packages hard-fail so a new matrix entry without a mapping update is caught. - ci.yml integrations matrix installs `.[dev]` directly -- all 21 packages declare the extra (audited 2026-05). - policy-validation.yml agent-os installs use `.[dev]` directly. Job behavior is unchanged for the existing matrix; install failures now surface explicitly instead of being masked into a less-complete install. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: verify ATR npm tarball SHA-512 before install `sync-atr-community-rules.yml` previously ran `npm install --no-save --no-package-lock agent-threat-rules@2.0.12` with no integrity check, so a malicious republish at the same version would silently flow into the auto-generated policy PR. New flow: 1. Resolve the tarball URL from the registry metadata for the pinned version. 2. Download the tarball. 3. Compute SHA-512 and compare against the committed `ATR_INTEGRITY` value (sha512-<base64>, same format npm uses internally). 4. Install from the local verified tarball. 5. Sanity-check the installed package version matches the pinned version. Any mismatch in step 3 aborts the workflow before sync runs. The pinned integrity value must be updated together with version bumps; the comment documents how to fetch a fresh value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci: scope write permissions per-job in single-job utility workflows Brings five auxiliary workflows in line with the convention already used by ci.yml / publish.yml / ai-pr-review.yml: top-level `contents: read` only, with write/read scopes attached to the single job that needs them. Files: - contributor-check.yml: issues:write + pull-requests:write moved to `check` job (required by .github/actions/contributor-check which posts comments/labels). - pr-size.yml: pull-requests:write moved to `size-label` job (codelytv/pr-size-labeler applies size/* labels). - labeler.yml: pull-requests:write moved to `label` job (actions/labeler). - pr-title-check.yml: pull-requests:read moved to `semantic-title` job (amannn/action-semantic-pull-request reads PR title/body); contents:read added at top. - require-maintainer-approval.yml: pull-requests:read moved to `check-approval` job (github-script calls pulls.listReviews). No behavior change today (single-job workflows have identical effective permissions either way) but eliminates surprise if a second job is added and inherits broader perms than intended. Per-job comments document which API call each permission unlocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A1+A9 ATR npm install runs lifecycle scripts and uses fixed tarball path -- currently FAILING A1: every npm install line in sync-atr-community-rules.yml must include --ignore-scripts. SHA-512 only verifies bytes-as-published; a compromised publisher re-publishing at the same version still executes lifecycle scripts in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. A9: /tmp/atr.tgz is predictable per runner; switch to mktemp with atr.XXXXXX.tgz template as defense-in-depth on shared runners. Both tests fail on this commit; the next commit makes them pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): block npm lifecycle scripts and unpredictable tarball path for ATR sync A1 (High): npm install verified the ATR tarball bytes via SHA-512 but still ran the package's preinstall/install/postinstall lifecycle scripts. A malicious republish that flips the upstream integrity hash would be rejected, but a compromised publisher who registers a *new* SHA still got arbitrary code execution in a job that holds contents:write + pull-requests:write + GITHUB_TOKEN. Add --ignore-scripts so the sync only reads files from node_modules/agent-threat-rules/rules/, which is all the workflow actually needs. A9 (Low): switch /tmp/atr.tgz to mktemp -t atr.XXXXXX.tgz to give the tarball an unpredictable per-run path. Cheap defense-in-depth against TOCTOU on shared (e.g. self-hosted) runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A2 toolkit-version regex accepts PEP 440 post/dev/local-version -- currently FAILING The original ([.+-][A-Za-z0-9._+-]+)? alternation allowed 3.7.0.post1, 3.7.0.dev1, and 3.7.0+local through validation. Pip resolves those to artifacts other than the canonical release, so the upstream check did not guarantee that the canonical version was installed. Test asserts the tightened regex literal across all 3 actions and that each README documents an 'Accepted version syntax' section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): tighten toolkit-version regex to reject post, dev, and local-version forms A2 (Medium): the previous regex appended `([.+-][A-Za-z0-9._+-]+)?` to the release/pre-release form, which accepts `3.7.0.post1`, `3.7.0.dev0`, `3.7.0+anything`, and `3.7.0-anything`. Those are all valid PEP 440 / pip syntax and let an attacker who can influence the toolkit-version input (e.g. via a downstream workflow that interpolates a user-controlled value) install a yanked or non-public distribution alongside the apparent release. Drop that optional group. Accept only X.Y.Z and X.Y.ZaN | X.Y.ZbN | X.Y.ZrcN. Update the three composite action READMEs with an Accepted version syntax section that documents what is accepted and what is rejected, plus a note in the breaking-change banner pointing to that section. Also reminds consumers to pin the action to `@v3` (the existing major-tag) so the new toolkit-version requirement does not break their pipeline at action-update time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A3+A6+A7 AI agent runner sanitizer + shell-safe output -- currently FAILING A3: 'response' output is currently undocumented as comment-only and there is no shell-safe variant; consumers wiring response into run: cause command injection. Test asserts a 'response-shell-safe' output and warning in action.yml. A6: neutralizeMentions only handled ASCII '@'. Attacker can bypass with U+FF20 fullwidth, µsoft#64;, @, or ZW-prefixed at-signs to mass-notify maintainers from AI-authored comments. A7: sanitizer leaves bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars, OSC-8 hyperlinks (ESC ] 8 ; ;), and skips NFKC normalization. Bidi flips rendered text, OSC-8 hides destinations behind plausible link text. All four tests fail on this commit because lib/sanitize.mjs does not yet exist and action.yml lacks the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): harden AI agent runner sanitization and add shell-safe output A6 (Medium): extend neutralizeMentions to catch HTML entities (`µsoft#64;`, `@` with optional leading zeros), Unicode fullwidth `U+FF20`, and zero-width-prefixed mentions (e.g. `@<ZWSP>user`). Previous regex only matched plain ASCII `@`. A7 (Medium): extend sanitizeForComment to strip bidi controls (U+202A-U+202E, U+2066-U+2069), zero-width chars (U+200B-U+200D, U+2060, U+FEFF), and OSC-8 hyperlinks (`ESC]8;;...BEL` / `ESC]8;;...ESC\`). NFKC-normalize input first so fullwidth `U+FF1A` collapsing to `::` cannot bypass the workflow-command line filters. A3 (Medium): document that the `response` output is comment-safe only and add a separate `response-shell-safe` output (base64-encoded) for downstream steps that must pass the AI response into a shell `run:` block. Sanitized comment text can still contain backticks, dollar expansions, and command-substitution patterns; base64 reduces the payload to the fixed alphabet [A-Za-z0-9+/=]. Refactor the sanitization helpers out of the heredoc-embedded runner script into `.github/actions/ai-agent-runner/lib/sanitize.mjs`, which action.yml concatenates ahead of the runtime body and which the new `tests/ci/test_ai_agent_sanitize.py` imports directly via a Node child process. Tests cover the base64 round-trip, all alt-encoding mention forms, and bidi/zero-width/OSC-8/NFKC stripping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A8 ai-pr-review RUN_MARKER varies across re-runs -- currently FAILING RUN_MARKER embeds GITHUB_RUN_ATTEMPT so a maintainer triggering a workflow re-run produces a new marker. The aggregator filter requires the marker to match prior comments, so AI summaries silently break across re-runs and the new run cannot find the prior agent comment to update or correlate with. Test parses ai-pr-review.yml, finds the RUN_MARKER assignment, and asserts GITHUB_RUN_ATTEMPT is absent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): make AI summary aggregator marker stable across re-runs A8 (Low): RUN_MARKER previously included GITHUB_RUN_ATTEMPT, so on workflow re-run the aggregator could not find comments from a previous attempt — leaving orphaned per-agent comments tagged with a marker the new attempt would never query. Drop the attempt suffix so RUN_ID alone identifies all comments from this logical workflow run, regardless of attempt count. Add an inline comment explaining why so a future refactor does not silently re-introduce the suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A4+A5 ESRP build tools lack hash pinning and rustVersion validation lacks comment -- currently FAILING A4: ESRP pip install pinned versions but not hashes and did not pass --no-deps; pip resolved transitives from PyPI at run time, giving anyone who could swap a pinned transitive code execution inside the release pipeline. A5: rustVersion regex validation is template-expanded at queue time but enforced at runtime; without a comment the operator cannot tell that ADO queue-time parameter ACLs are the primary defense and the regex is the secondary one. Tests fail because no release-tools.txt lockfile exists, the install step does not request --require-hashes, and no explanatory comment surrounds the rustVersion block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): hash-pin ESRP build tools and document rustVersion validation order A4 (Medium): the PyPI build stage previously installed `build==1.2.1 setuptools==80.9.0` with no integrity verification and no transitive pinning, so a compromised mirror or a yanked-and-re-uploaded wheel could ship a backdoored `build` or `setuptools` straight into our signing pipeline. Generate `release-tools.txt` via pip-tools with `--generate-hashes --allow-unsafe` and switch the ESRP step to `--require-hashes --no-deps -r release-tools.txt`. Commit both the input (`release-tools.in`) and the resolved lockfile. Header in the lockfile documents the regeneration command. A5 (Low): add an inline comment near the rustVersion validation in esrp-publish.yml explaining the template-expansion-vs-runtime-regex order — the regex is defense-in-depth for a future refactor that promotes rustVersion to a runtime variable, and the primary defense remains the ADO queue-time parameter ACL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A10 AI agent runner accepts arbitrary output-mode -- currently FAILING Without an allowlist + permission-requirement map, a future caller setting output-mode to a value whose required permissions are not granted produces silent failures instead of fail-fast. Test asserts both ALLOWED_OUTPUT_MODES and OUTPUT_MODE_REQUIRED_PERMS are present in the runner script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): startup-check output-mode in AI agent runner A10 (Low): when a caller sets `output-mode: pr-review` but only grants `pull-requests: read`, the action previously failed mid-execution after burning LLM tokens. Worse, a future caller-input bug that lets an untrusted value reach `output-mode` could silently steer postResults into an unintended code path. Add an allow-list check at the top of main() that rejects unknown modes before any network I/O. Document the workflow permissions each output mode requires so a caller can grant the minimum. Keep ai-pr-review.yml on `pull-requests: read` (the existing mode is `pr-comment` which falls back to `issues: write`); upgrading the permission is the caller's decision, not the action's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for A11 missing BREAKING_CHANGES.md for toolkit-version requirement -- currently FAILING Making toolkit-version required is a breaking change for downstream consumers; they need a migration document to pin to @V3 and start supplying the input. Test asserts BREAKING_CHANGES.md exists at repo root and labels the change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): document toolkit-version breaking change in BREAKING_CHANGES.md A11: `toolkit-version` is now required on the three composite actions, with a strict regex that rejects post / dev / local-version forms. Add a top-level BREAKING_CHANGES.md entry documenting the migration path and recommending consumers pin to `@v3` (the existing major-tag) so the new requirement does not break their pipeline at action-update time. The actual `@v3` tag cut is a release-time decision and is intentionally not performed in this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * test(ci): regression for ci-test.txt install swallows failures via || true -- currently FAILING Two install steps in ci.yml used 'pip install ... --require-hashes -r ci-test.txt 2>/dev/null || true', which swallowed every pip failure including hash mismatches, missing files, and resolver errors. CI then proceeded as if install had succeeded, defeating the purpose of --require-hashes. Test parses ci.yml, finds every line referencing ci-test.txt, and asserts no '|| true' swallow is adjacent. Fails on this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * ci(security): drop || true error swallow on hash-pinned ci-test.txt install Deferred next-pass item from the red-team review. The two `pip install --require-hashes -r .../ci-test.txt 2>/dev/null || true` lines in ci.yml masked any failure of the hash-pinned install — including the security-relevant hash-mismatch case that `--require-hashes` is supposed to surface. Replace with a positive `[ -f ... ]` guard: if the file exists, the install must succeed; otherwise emit a warning. ci-test.txt currently exists in the repo, so the previous code path was always masking real failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> --------- Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ention spam
Address blocking and non-blocking findings from the red-team review of the
two-workflow SBOM diff design:
PR-misrouting (blocking)
- sbom-diff-comment.yml: prefer the authoritative
workflow_run.pull_requests array (populated for same-repo PRs); fall
back to listPullRequestsAssociatedWithCommit only for fork PRs.
- Re-fetch the candidate PR and require pr.head.sha === workflow_run.head_sha
before posting. Skip with a warning otherwise. This closes the stale-
comment race where commit A's slow trusted run could overwrite the comment
for the newer commit B.
- Add a concurrency group keyed on workflow_run.head_sha with
cancel-in-progress: true so a newer push pre-empts in-flight stale runs.
DoS via unbounded sections (blocking)
- diff_sbom.py: cap added/removed/bumped each at 500 (was: added only).
- Add DEFAULT_MAX_SBOM_BYTES (64 MiB) and reject oversized SBOMs at load
time via path.stat() before invoking json.load, so a hostile lockfile
cannot balloon process memory before truncation.
- render_markdown emits parallel truncation notices for all three sections.
- New CLI flags: --max-removed, --max-bumped, --max-sbom-bytes.
Mention / notification spam (non-blocking)
- _sanitize_cell now neutralises GitHub auto-link triggers: '#' -> 'µsoft#35;'
then '@' -> 'µsoft#64;' (order matters - reversing it clobbers the entity).
Hostile package names embedding @user, @org/team, or microsoft#1234 can no
longer ping people or autolink in the bot comment.
Tests
- +5 new tests: mention neutralisation, removed/bumped truncation caps,
parallel render notices, and oversized SBOM rejection (42 total).
- 211/211 scripts/tests/ pass, ruff clean.
Signed-off-by: Jack Batzner <jackbatzner@microsoft.com>
…ention spam
Address blocking and non-blocking findings from the red-team review of the
two-workflow SBOM diff design:
PR-misrouting (blocking)
- sbom-diff-comment.yml: prefer the authoritative
workflow_run.pull_requests array (populated for same-repo PRs); fall
back to listPullRequestsAssociatedWithCommit only for fork PRs.
- Re-fetch the candidate PR and require pr.head.sha === workflow_run.head_sha
before posting. Skip with a warning otherwise. This closes the stale-
comment race where commit A's slow trusted run could overwrite the comment
for the newer commit B.
- Add a concurrency group keyed on workflow_run.head_sha with
cancel-in-progress: true so a newer push pre-empts in-flight stale runs.
DoS via unbounded sections (blocking)
- diff_sbom.py: cap added/removed/bumped each at 500 (was: added only).
- Add DEFAULT_MAX_SBOM_BYTES (64 MiB) and reject oversized SBOMs at load
time via path.stat() before invoking json.load, so a hostile lockfile
cannot balloon process memory before truncation.
- render_markdown emits parallel truncation notices for all three sections.
- New CLI flags: --max-removed, --max-bumped, --max-sbom-bytes.
Mention / notification spam (non-blocking)
- _sanitize_cell now neutralises GitHub auto-link triggers: '#' -> 'µsoft#35;'
then '@' -> 'µsoft#64;' (order matters - reversing it clobbers the entity).
Hostile package names embedding @user, @org/team, or microsoft#1234 can no
longer ping people or autolink in the bot comment.
Tests
- +5 new tests: mention neutralisation, removed/bumped truncation caps,
parallel render notices, and oversized SBOM rejection (42 total).
- 211/211 scripts/tests/ pass, ruff clean.
Signed-off-by: Jack Batzner <jackbatzner@microsoft.com>
…design (#2866) * feat(supply-chain): add PR-time SBOM diff workflow Generates SPDX-JSON SBOMs for both base and head of every PR, computes the added/removed/version-bumped delta, and posts a markdown summary as an idempotent PR comment (hidden marker keeps repeat runs from stacking). Catches transitive dependency creep that lockfile diffs alone miss. - scripts/diff_sbom.py: pure-stdlib SPDX-JSON diff renderer; sanitises hostile package names against markdown/log injection; caps rendered added entries at 500 to prevent comment-flood DoS. - scripts/tests/test_diff_sbom.py: 37 unit tests (purl parsing, sanitisation, diff math, truncation cap, render, end-to-end). - .github/workflows/sbom-diff.yml: on: pull_request (not pull_request_target); workflow-level contents:read; pull-requests:write scoped to the comment job only; every action SHA-pinned; BASE_REF and HEAD_REF passed via env to avoid GHA expression shell-injection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * refactor(supply-chain): split SBOM diff into pull_request + workflow_run pair Adopt the industry-standard two-workflow pattern for safely commenting on fork PRs. Eliminates the residual risk that attacker-controlled `scripts/diff_sbom.py` from a fork PR could influence the comment body. Changes: - sbom-diff.yml: drop the comment job. Job becomes generate-only; runs anchore against base + head checkouts and uploads SBOM JSON files as artifact `sbom-diff-inputs`. Workflow-level permissions reduced to `contents: read` (no job needs write anymore). - sbom-diff-comment.yml (new): `on: workflow_run` against sbom-diff.yml. Runs in trusted base-repo context. Sparse-checks-out only `scripts/diff_sbom.py` from the default branch. Downloads the artifact from the triggering run via run-id. Re-derives PR identity from the workflow_run head SHA via the GitHub API (`listPullRequestsAssociatedWithCommit`) -- never trusts artifact contents for routing. Renders markdown via the trusted script. Posts or updates the comment with the `<!-- sbom-diff-bot -->` marker. Security properties preserved or improved: - Fork PRs now get a comment (previously fork PRs failed with 403 because pull_request downgrades the token; workflow_run runs in base context with a full token). - Attacker control of the comment body is eliminated: the rendering script and the PR-identity lookup both live in the trusted half. - PR-routing tampering is impossible: artifact contains data only; PR number/refs come from the GitHub API keyed on workflow_run.head_sha. - Bootstrap (this PR) gracefully no-ops the comment step when the diff script is not yet on the default branch, surfacing a warning so reviewers know to inspect the artifact directly. Sign-off: Jack Batzner <jackbatzner@microsoft.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * fix(supply-chain): harden SBOM diff against PR-misrouting, DoS, and mention spam Address blocking and non-blocking findings from the red-team review of the two-workflow SBOM diff design: PR-misrouting (blocking) - sbom-diff-comment.yml: prefer the authoritative workflow_run.pull_requests array (populated for same-repo PRs); fall back to listPullRequestsAssociatedWithCommit only for fork PRs. - Re-fetch the candidate PR and require pr.head.sha === workflow_run.head_sha before posting. Skip with a warning otherwise. This closes the stale- comment race where commit A's slow trusted run could overwrite the comment for the newer commit B. - Add a concurrency group keyed on workflow_run.head_sha with cancel-in-progress: true so a newer push pre-empts in-flight stale runs. DoS via unbounded sections (blocking) - diff_sbom.py: cap added/removed/bumped each at 500 (was: added only). - Add DEFAULT_MAX_SBOM_BYTES (64 MiB) and reject oversized SBOMs at load time via path.stat() before invoking json.load, so a hostile lockfile cannot balloon process memory before truncation. - render_markdown emits parallel truncation notices for all three sections. - New CLI flags: --max-removed, --max-bumped, --max-sbom-bytes. Mention / notification spam (non-blocking) - _sanitize_cell now neutralises GitHub auto-link triggers: '#' -> '#' then '@' -> '@' (order matters - reversing it clobbers the entity). Hostile package names embedding @user, @org/team, or #1234 can no longer ping people or autolink in the bot comment. Tests - +5 new tests: mention neutralisation, removed/bumped truncation caps, parallel render notices, and oversized SBOM rejection (42 total). - 211/211 scripts/tests/ pass, ruff clean. Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * chore: fix CI failures on PR #2866 Three CI failures fixed: 1. policy-engine-ci.yml drift (Check generated workflows + inline-script-tests): regenerated via scripts/ci/generate_workflows.py --write. Drift was pre-existing on main; surfaces on every PR until regenerated. 2. spell-check: replace UK spellings with US (sanitises -> sanitizes, neutralise -> neutralize) in scripts/diff_sbom.py and the workflow. 3. spell-check: add legitimate proper nouns and jargon to repo dictionary (.cspell-repo-terms.txt): anchore (vendor), octocat (GitHub example), sboms (acronym plural), Syft/syft (Anchore tool). Local validation: - pytest scripts/tests/test_diff_sbom.py: 42 passed - pytest tests/ci/test_generate_workflows.py: 13 passed - ruff check: All checks passed! Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(scripts): cover multi-version overlap + clarify SBOM diff comments Address review feedback on PR #2866: - Add unit test exercising multi-version package overlap (e.g., lodash@4.17.20 + 4.17.21 in base, 4.17.21 + 4.17.22 in head). Documents that diff_sboms keys by (ecosystem, name) and emits a single bumped entry (oldest -> newest), matching the design choice favoring readable PR comments over per-version fan-out. - Expand inline comment on _extract_packages to explain why SPDX synthetic root packages (SPDXRef-DOCUMENT, name='') are skipped. - Add block comment on actions/checkout pin documenting that df4cb1c0 (v6.0.3) matches the convention used by 83 workflows on main. Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> --------- Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…design (microsoft#2866) * feat(supply-chain): add PR-time SBOM diff workflow Generates SPDX-JSON SBOMs for both base and head of every PR, computes the added/removed/version-bumped delta, and posts a markdown summary as an idempotent PR comment (hidden marker keeps repeat runs from stacking). Catches transitive dependency creep that lockfile diffs alone miss. - scripts/diff_sbom.py: pure-stdlib SPDX-JSON diff renderer; sanitises hostile package names against markdown/log injection; caps rendered added entries at 500 to prevent comment-flood DoS. - scripts/tests/test_diff_sbom.py: 37 unit tests (purl parsing, sanitisation, diff math, truncation cap, render, end-to-end). - .github/workflows/sbom-diff.yml: on: pull_request (not pull_request_target); workflow-level contents:read; pull-requests:write scoped to the comment job only; every action SHA-pinned; BASE_REF and HEAD_REF passed via env to avoid GHA expression shell-injection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * refactor(supply-chain): split SBOM diff into pull_request + workflow_run pair Adopt the industry-standard two-workflow pattern for safely commenting on fork PRs. Eliminates the residual risk that attacker-controlled `scripts/diff_sbom.py` from a fork PR could influence the comment body. Changes: - sbom-diff.yml: drop the comment job. Job becomes generate-only; runs anchore against base + head checkouts and uploads SBOM JSON files as artifact `sbom-diff-inputs`. Workflow-level permissions reduced to `contents: read` (no job needs write anymore). - sbom-diff-comment.yml (new): `on: workflow_run` against sbom-diff.yml. Runs in trusted base-repo context. Sparse-checks-out only `scripts/diff_sbom.py` from the default branch. Downloads the artifact from the triggering run via run-id. Re-derives PR identity from the workflow_run head SHA via the GitHub API (`listPullRequestsAssociatedWithCommit`) -- never trusts artifact contents for routing. Renders markdown via the trusted script. Posts or updates the comment with the `<!-- sbom-diff-bot -->` marker. Security properties preserved or improved: - Fork PRs now get a comment (previously fork PRs failed with 403 because pull_request downgrades the token; workflow_run runs in base context with a full token). - Attacker control of the comment body is eliminated: the rendering script and the PR-identity lookup both live in the trusted half. - PR-routing tampering is impossible: artifact contains data only; PR number/refs come from the GitHub API keyed on workflow_run.head_sha. - Bootstrap (this PR) gracefully no-ops the comment step when the diff script is not yet on the default branch, surfacing a warning so reviewers know to inspect the artifact directly. Sign-off: Jack Batzner <jackbatzner@microsoft.com> Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * fix(supply-chain): harden SBOM diff against PR-misrouting, DoS, and mention spam Address blocking and non-blocking findings from the red-team review of the two-workflow SBOM diff design: PR-misrouting (blocking) - sbom-diff-comment.yml: prefer the authoritative workflow_run.pull_requests array (populated for same-repo PRs); fall back to listPullRequestsAssociatedWithCommit only for fork PRs. - Re-fetch the candidate PR and require pr.head.sha === workflow_run.head_sha before posting. Skip with a warning otherwise. This closes the stale- comment race where commit A's slow trusted run could overwrite the comment for the newer commit B. - Add a concurrency group keyed on workflow_run.head_sha with cancel-in-progress: true so a newer push pre-empts in-flight stale runs. DoS via unbounded sections (blocking) - diff_sbom.py: cap added/removed/bumped each at 500 (was: added only). - Add DEFAULT_MAX_SBOM_BYTES (64 MiB) and reject oversized SBOMs at load time via path.stat() before invoking json.load, so a hostile lockfile cannot balloon process memory before truncation. - render_markdown emits parallel truncation notices for all three sections. - New CLI flags: --max-removed, --max-bumped, --max-sbom-bytes. Mention / notification spam (non-blocking) - _sanitize_cell now neutralises GitHub auto-link triggers: '#' -> 'µsoft#35;' then '@' -> 'µsoft#64;' (order matters - reversing it clobbers the entity). Hostile package names embedding @user, @org/team, or microsoft#1234 can no longer ping people or autolink in the bot comment. Tests - +5 new tests: mention neutralisation, removed/bumped truncation caps, parallel render notices, and oversized SBOM rejection (42 total). - 211/211 scripts/tests/ pass, ruff clean. Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> * chore: fix CI failures on PR microsoft#2866 Three CI failures fixed: 1. policy-engine-ci.yml drift (Check generated workflows + inline-script-tests): regenerated via scripts/ci/generate_workflows.py --write. Drift was pre-existing on main; surfaces on every PR until regenerated. 2. spell-check: replace UK spellings with US (sanitises -> sanitizes, neutralise -> neutralize) in scripts/diff_sbom.py and the workflow. 3. spell-check: add legitimate proper nouns and jargon to repo dictionary (.cspell-repo-terms.txt): anchore (vendor), octocat (GitHub example), sboms (acronym plural), Syft/syft (Anchore tool). Local validation: - pytest scripts/tests/test_diff_sbom.py: 42 passed - pytest tests/ci/test_generate_workflows.py: 13 passed - ruff check: All checks passed! Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(scripts): cover multi-version overlap + clarify SBOM diff comments Address review feedback on PR microsoft#2866: - Add unit test exercising multi-version package overlap (e.g., lodash@4.17.20 + 4.17.21 in base, 4.17.21 + 4.17.22 in head). Documents that diff_sboms keys by (ecosystem, name) and emits a single bumped entry (oldest -> newest), matching the design choice favoring readable PR comments over per-version fan-out. - Expand inline comment on _extract_packages to explain why SPDX synthetic root packages (SPDXRef-DOCUMENT, name='') are skipped. - Add block comment on actions/checkout pin documenting that df4cb1c0 (v6.0.3) matches the convention used by 83 workflows on main. Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> --------- Signed-off-by: Jack Batzner <jackbatzner@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Summary
Ports the production-grade CMVK (Cross-Model Verification Kernel) implementation from the internal agent-governance repository to the public OSS toolkit.
Changes
New Files
Upgraded Files
Tests
Tracking
Closes #56