Skip to content

feat(cmvk): port production Cross-Model Verification Kernel from internal - #64

Merged
Imran Siddique (imran-siddique) merged 1 commit into
mainfrom
feat/port-cmvk-production
Mar 7, 2026
Merged

Imran Siddique (imran-siddique) merged 1 commit into
mainfrom
feat/port-cmvk-production

Conversation

@imran-siddique

Copy link
Copy Markdown
Collaborator

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

  • constitutional.py (904L): ConstitutionalValidator with principles, evaluators, and domain-specific validation
  • benchmarks.py (478L): Benchmark suite for single-model vs multi-model verification comparison
  • profiles.py (300L): Threshold profiles for carbon, financial, medical, strict, and lenient domains

Upgraded Files

  • verification.py (956L, was 588L): Added batch verification, explainability engine, audit trail, async support
  • metrics.py (473L, was 347L): Added weighted distance functions, enhanced metric computation
  • init.py (218L): Re-exports for new modules

Tests

  • Comprehensive test suite with 26 test files covering unit, integration, and enhanced feature tests
  • All tests pass locally

Tracking

Closes #56

- 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>
@github-actions github-actions Bot added the tests label Mar 7, 2026
@github-actions

github-actions Bot commented Mar 7, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), and benchmarks.py (benchmark suite for single vs multi-model comparison)
  • Upgrades verification.py and metrics.py from 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__.py re-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.

Comment on lines +61 to +64
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Optional, Protocol, Sequence, Union

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +53
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"
),
]
)

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +370 to +393
# 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 {}
)

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +257 to +258
# Use profile's default metric if none specified
if metric == "cosine" and profile.default_metric != "cosine":

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Suggested change
# 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:

Copilot uses AI. Check for mistakes.
avg_latency_ms=avg_latency,
by_category={},
by_difficulty={},
timestamp=datetime.utcnow().isoformat(),

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +344 to +347
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

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
@imran-siddique
Imran Siddique (imran-siddique) merged commit d2e6cee into main Mar 7, 2026
26 checks passed
@imran-siddique
Imran Siddique (imran-siddique) deleted the feat/port-cmvk-production branch March 7, 2026 21:19
Jack Batzner (jackbatzner) added a commit to jackbatzner/agent-governance-toolkit that referenced this pull request May 29, 2026
…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, &microsoft#64;, &#x40;, 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>
Jack Batzner (jackbatzner) added a commit to jackbatzner/agent-governance-toolkit that referenced this pull request May 29, 2026
…output

A6 (Medium): extend neutralizeMentions to catch HTML entities (`&microsoft#64;`,
`&#x40;` 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>
Imran Siddique (imran-siddique) pushed a commit that referenced this pull request May 29, 2026
…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, &#64;, &#x40;, 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 (`&#64;`,
`&#x40;` 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>
MohammadHaroonAbuomar pushed a commit to MohammadHaroonAbuomar/agt-acs that referenced this pull request Jun 1, 2026
- 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>
MohammadHaroonAbuomar pushed a commit to MohammadHaroonAbuomar/agt-acs that referenced this pull request Jun 1, 2026
…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, &microsoft#64;, &#x40;, 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 (`&microsoft#64;`,
`&#x40;` 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>
Dhinesh Ponnarasan (DhineshPonnarasan) pushed a commit to DhineshPonnarasan/agent-governance-toolkit that referenced this pull request Jun 1, 2026
…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, &microsoft#64;, &#x40;, 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 (`&microsoft#64;`,
`&#x40;` 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>
Jack Batzner (jackbatzner) added a commit to jackbatzner/agent-governance-toolkit that referenced this pull request Jun 10, 2026
…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: '#' -> '&microsoft#35;'
    then '@' -> '&microsoft#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>
Jack Batzner (jackbatzner) added a commit to jackbatzner/agent-governance-toolkit that referenced this pull request Jun 11, 2026
…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: '#' -> '&microsoft#35;'
    then '@' -> '&microsoft#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>
Imran Siddique (imran-siddique) pushed a commit that referenced this pull request Jun 14, 2026
…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: '#' -> '&#35;'
    then '@' -> '&#64;' (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>
jlaportebot (jlaportebot) pushed a commit to jlaportebot/agent-governance-toolkit that referenced this pull request Jun 17, 2026
…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: '#' -> '&microsoft#35;'
    then '@' -> '&microsoft#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Port production CMVK (Cross-Model Verification Kernel) from internal

3 participants