Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/agent-supply-chain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Agent Rust supply-chain checks

on:
pull_request:
paths:
- ".github/workflows/agent-supply-chain.yml"
- "apps/maple-agent/deny.toml"
- "apps/maple-agent/**/Cargo.toml"
- "apps/maple-agent/Cargo.lock"
- "proxy/Cargo.toml"
- "sdk/rust/Cargo.toml"
push:
branches: [master]
paths:
- ".github/workflows/agent-supply-chain.yml"
- "apps/maple-agent/deny.toml"
- "apps/maple-agent/**/Cargo.toml"
- "apps/maple-agent/Cargo.lock"
- "proxy/Cargo.toml"
- "sdk/rust/Cargo.toml"
schedule:
- cron: "43 7 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
agent-cargo-deny:
name: Agent RustSec advisories and malicious crates
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

- name: Check Agent RustSec advisories and incident denylist
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1; cargo-deny 0.20.2
with:
# Match the component toolchain for Cargo metadata; no crate is built.
rust-version: 1.98.0
manifest-path: apps/maple-agent/Cargo.toml
command: check advisories bans
arguments: --config apps/maple-agent/deny.toml --all-features --locked
8 changes: 8 additions & 0 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,14 @@ a release build. `just ci` is the full local format, lint, build and test gate;
`just release` separately validates the optimized binary. PR jobs have no
signing or publishing credentials. See the root agent guide for shared checks.

The independent `.github/workflows/agent-supply-chain.yml` lane checks Agent's
locked dependency graph against current RustSec advisories and the repository's
malicious-crate denylist on dependency changes and daily. It uses the component's
`deny.toml`, scans all features, and has no signing or publishing credentials.
Vulnerabilities, unsoundness and unmaintained-crate advisories block this lane;
no advisory exceptions are inherited from other components. Findings require
review and a separate dependency fix rather than suppression during import.

## Update and release boundary

The launch check accepts only stable `maple-agent-vX.Y.Z` releases and selects
Expand Down
26 changes: 26 additions & 0 deletions apps/maple-agent/deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[advisories]
# Scan the whole Agent graph, including transitive dependencies. Do not inherit
# advisory exceptions from another component or hide new findings at import.
unsound = "all"
unmaintained = "all"

[bans]
# Emergency supply-chain containment for the August 2026 crates.io campaign.
# Keep these explicit package bans alongside the blocking RustSec checks.
multiple-versions = "allow"
wildcards = "allow"
deny = [
# Legitimate crates whose malicious releases were removed. Block the
# compromised release and all later releases pending maintainer recovery.
{ crate = "arrayref@>=0.3.10", reason = "Compromised release: RUSTSEC-2026-0260" },
{ crate = "append-only-vec@>=0.1.9", reason = "Compromised release: RUSTSEC-2026-0262" },
{ crate = "internment@>=0.8.7", reason = "Compromised release: RUSTSEC-2026-0266" },

# Campaign-associated malicious package names. Deny every version.
{ crate = "arone", reason = "Malicious crate: RUSTSEC-2026-0259" },
{ crate = "aronenao", reason = "Malicious crate: RUSTSEC-2026-0261" },
{ crate = "tinymember", reason = "Malicious crate: RUSTSEC-2026-0263" },
{ crate = "proc-macro-en", reason = "Malicious crate: RUSTSEC-2026-0264" },
{ crate = "proc-macro1", reason = "Malicious crate: RUSTSEC-2026-0265" },
{ crate = "aovine", reason = "Campaign-associated malicious crate removed from crates.io" },
]
1 change: 1 addition & 0 deletions scripts/ci/agent_change_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"README.md",
"LICENSE",
".gitignore",
"deny.toml", # Validated by the independent supply-chain lane.
}
)
AGENT_INERT_PREFIXES = ("docs/",)
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/test_agent_change_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def test_docs_and_standalone_dependency_inputs_skip_both_apps(self):
for path in (
"apps/maple-agent/README.md", "apps/maple-agent/AGENTS.md",
"apps/maple-agent/CLAUDE.md", "apps/maple-agent/LICENSE",
"apps/maple-agent/deny.toml", ".github/workflows/agent-supply-chain.yml",
"apps/maple-agent/docs/development.md", "README.md",
"sdk/rust/README.md", "sdk/rust/tests/client.rs", "sdk/rust/Cargo.lock",
"proxy/README.md", "proxy/tests/health.rs", "proxy/Cargo.lock",
Expand Down
53 changes: 53 additions & 0 deletions scripts/ci/test_agent_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shutil
import subprocess
import tempfile
import tomllib
import unittest


Expand Down Expand Up @@ -110,6 +111,58 @@ def test_namespaced_agent_releases_do_not_enter_research_jobs(self):
self.assertIn("github.event.workflow_run.event == 'release'", condition)


class AgentSupplyChainTests(unittest.TestCase):
def test_scan_has_no_credentials_publication_or_privileged_events(self):
config = workflow("agent-supply-chain.yml")
self.assertEqual(set(config["on"]),
{"push", "pull_request", "schedule", "workflow_dispatch"})
self.assertEqual(config["permissions"], {"contents": "read"})
for value in strings(config):
self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b")
self.assertEqual(set(config["jobs"]), {"agent-cargo-deny"})
job = config["jobs"]["agent-cargo-deny"]
self.assertEqual(job["runs-on"], "ubuntu-latest")
self.assertLessEqual(job["timeout-minutes"], 10)
self.assertNotIn("environment", job)
self.assertNotIn("permissions", job)
self.assertNotIn("continue-on-error", job)
self.assertEqual(len(job["steps"]), 2)
for step in job["steps"]:
self.assertNotIn("run", step)
self.assertNotIn("continue-on-error", step)
self.assertRegex(step["uses"],
r"^(actions/checkout|EmbarkStudios/cargo-deny-action)@[0-9a-f]{40}$")
self.assertIs(job["steps"][0]["with"]["persist-credentials"], False)
scan = job["steps"][1]["with"]
self.assertEqual(scan, {
"rust-version": "1.98.0",
"manifest-path": "apps/maple-agent/Cargo.toml",
"command": "check advisories bans",
"arguments": "--config apps/maple-agent/deny.toml --all-features --locked",
})

def test_scan_covers_each_dependency_manifest_and_the_agent_lock(self):
config = workflow("agent-supply-chain.yml")
self.assertEqual(config["on"]["push"]["branches"], ["master"])
paths = config["on"]["pull_request"]["paths"]
self.assertEqual(paths, config["on"]["push"]["paths"])
self.assertEqual(set(paths), {
".github/workflows/agent-supply-chain.yml",
"apps/maple-agent/deny.toml", "apps/maple-agent/**/Cargo.toml",
"apps/maple-agent/Cargo.lock", "proxy/Cargo.toml", "sdk/rust/Cargo.toml",
})
self.assertEqual(len(config["on"]["schedule"]), 1)
self.assertRegex(config["on"]["schedule"][0]["cron"], r"^\d+ \d+ \* \* \*$")

def test_agent_policy_does_not_inherit_advisory_exceptions(self):
config = tomllib.loads((ROOT / "apps/maple-agent/deny.toml").read_text())
self.assertEqual(config["advisories"], {"unsound": "all", "unmaintained": "all"})
# Incident containment applies to the new component too. An intentional
# policy update must reconcile both lists rather than accidentally omit it.
sdk = tomllib.loads((ROOT / "sdk/deny.toml").read_text())
self.assertEqual(config["bans"]["deny"], sdk["bans"]["deny"])


class AgentDiffSelectionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
Expand Down
Loading