MAF Agent Skills Evaluation
Microsoft Agent Framework's Agent Skills feature (GA'd 2026‑07‑07) lets an agent progressively disclose
capabilities through three stable tools — load_skill, read_skill_resource, run_skill_script — instead of
stuffing every capability into the system prompt up front. AgentEval evaluates and governs that surface end to
end: assertions on the disclosure trace, a compliance scanner for SKILL.md authoring, a dedicated red‑team
attack for a poisoned skill description, deterministic governance gates for run_skill_script code execution,
and a composite Skill Health & Security Index.
None of this requires a special build — it's the same AgentEval.Core / AgentEval.MAF / AgentEval.RedTeam
packages you already use, plus one new package-free namespace, AgentEval.Skills.
Live-verified sample:
samples/AgentEval.AgentSkillsEvalruns all seven phases below against a real Azure OpenAI agent and a real file-based skill fixture — every pass/fail line in its output is keyed on a real trace, never a bare claim.
What ships
| Phase | Capability | Status |
|---|---|---|
| 1 | Assertions + progressive-disclosure metric | Inline-ready |
| 2 | Compliance scanner (SKILL.md authoring + governance rules) |
Inline-ready |
| 3 | Skill-injection red-team attack | Shadow-only (judge did not clear calibration on this surface — see below) |
| 3 | run_skill_script governance gates |
Inline-ready (deterministic, no calibration debt) |
| 4a/4b | Skill Health & Security Index + hash-pin drift detection | Inline-ready (both deterministic) |
| 4c | Skill fuzzing, canary-skill honeypot, typosquat detection | Not built — deprioritized, see strategy/TODO.md |
1 — Assertions and the disclosure-efficiency metric
AgentEval.Assertions.SkillUsageAssertions extends the same ToolUsageAssertions/ToolCallAssertion fluent
API you already use for ordinary tools — no new MAF-type coupling; AgentEval.Core still never references
Microsoft.Agents.AI. Tool and argument names live in one place, AgentEval.Skills.SkillToolNames.
result.ToolUsage!.Should()
.HaveLoadedSkill("expense-report")
.And().HaveReadSkillResource("expense-report", "resources/policy.md").AfterTool(SkillToolNames.LoadSkill)
.And().HaveDisclosedProgressively()
.And().NotHaveRunSkillScript(because: "a policy lookup does not require running the compliance script");
HaveLoadedSkill/HaveReadSkillResource/HaveRunSkillScriptmatch by argument value (the skill/resource/script name), not just tool name, and degrade to a key-agnostic fallback if a future MAF version renames a parameter.HaveDisclosedProgressively()asserts everyread_skill_resource/run_skill_scriptcall is preceded by aload_skillfor the same skill.NotHaveRunSkillScriptis a safety-policy assertion (maps toNeverCallTool, requires abecause).SkillContractAssertions.AssertSkillWellFormedis a zero-cost sanity check you can run before wiring a skill into an agent at all.
The companion metric, AgentEval.Metrics.Agentic.SkillDisclosureEfficiencyMetric
(code_skill_disclosure_efficiency), scores the observable load_skill → read_skill_resource → run_skill_script funnel: disclosure-order validity, load precision (redundant loads / "load storms"), and an
optional load-selection F1 when EvaluationContext.Properties["expected_skills"] is supplied. It never
fabricates a selection score without ground truth, and it never fabricates an "advertise" stage count — the
skill-inventory listing injected into the system prompt isn't a tool call and isn't observable from a
ToolUsageReport.
2 — Compliance scanner
AgentEval.Skills.SkillComplianceValidator (pure, MAF-free, lives in AgentEval.Core) checks a skill's GA
SKILL.md rules (name/description/compatibility) plus governance flags
(ScriptRequiresGovernanceReview, ResourceFromUntrustedSource, AllowedToolsExperimental) against an
AgentEval-owned SkillManifest DTO.
AgentEval.MAF.Skills.MafSkillScanner is the one adapter that touches a live AgentSkill/AgentSkillsSource:
var report = await MafSkillScanner.ScanFileSkillsAsync(skillPath, scanAgent);
Console.WriteLine(SkillComplianceReportRenderer.RenderConsole(report));
SkillComplianceReportRenderer renders console, Markdown, or JSON output.
Honest, documented limitation: MAF exposes no public API to enumerate a file skill's resources/scripts
(AgentFileSkill stores them in private fields — verified against the live 1.13.0 assembly), so the scanner
re-derives the inventory from the resources//scripts/ directory convention on disk. Non-file sources
(in-memory/class/MCP skills) are honestly reported with zero resources/scripts rather than guessed — this
is locked in by a regression test, not silently swallowed.
CLI: the same scanner is reachable without writing any code — agenteval skills scan <path> (console/
markdown/json output, --fail-on-noncompliant for a CI gate). Credential-free and offline (no model call in
the scan itself — a no-op agent satisfies MAF's own AgentSkillsSourceContext constructor requirement only).
See CLI reference. v1 is compliance-only, not the full Security Index below.
Baseline ledger + repo-wide discovery (Wave 1): --write-baseline captures a timestamped snapshot
(structural fingerprint + full file-content hash per skill, reusing ManifestFingerprint/
ManifestDriftDetector — the same primitive McpToolDescriptionPoisoningGate and skill manifest drift
detection already use) into an append-only ledger (AgentEval.Skills.ISkillBaselineStore/
JsonFileSkillBaselineStore, mirroring — not subtyping — the Memory benchmark's JsonFileBaselineStore
pattern). agenteval skills baseline list|diff|history inspects it. --repo scans every known
skill-directory convention under a repo root (AgentEval.Skills.AgentSkillDirectoryConventions) and
aggregates the results. See CLI reference for the full command surface.
Cross-location drift + trust-on-first-use (Wave 2): two governance signals built directly on Wave 1's
content hashing, both surfaced as ordinary SkillComplianceFindings (so they render in console/markdown/
json output automatically, no new schema):
- Cross-location content drift — always checked on a
--reposcan (no extra flag; a single-directory scan has only one location by definition). Groups every discovered skill by name across all conventions found under the repo root; if the same name resolves to two or more DIFFERENT content hashes, that's flagged (Mediumseverity — a governance signal to investigate, since the divergence may be deliberate per-tool customization rather than drift or poisoning).internal static SkillsScanCommand.DetectCrossLocationDrift. - Trust-on-first-use reputation matching — opt-in via
--check-baseline. Loads the baseline ledger's full history (ISkillBaselineStore.ListAsync) and checks whether each scanned skill's current content hash already appears in ANY prior snapshot for the same name; a match is reported as an informational (Lowseverity) "✓ matches a previously-vetted copy" finding, naming the most recent match's capture date. Meaningless on a first-ever scan — pair with--write-baselineon earlier runs to build the history it reads.internal static SkillsScanCommand.DetectPreviouslyVetted.
Neither feature is High severity — both are informational/governance signals, not automatic compliance
failures, so --fail-on-noncompliant is unaffected by either.
Filesystem multi-repo scan (Wave 3a): agenteval skills scan-workspace <path> treats <path> as a folder
of already-cloned repos — each immediate subdirectory is one repo — and runs the existing --repo pipeline
against every one of them, combining the results into one report. Zero new discovery code: it's a fan-out loop
over the same pipeline --repo already uses, per repo. Filesystem-only — no network, no credentials, no new
trust surface; the operator's own clone step (git clone / gh repo clone, their own access, their own
tooling) is what decides which repos are visible here, deliberately kept outside this verb's scope. Every
entry is tagged with a {repoFolder}/{conventionPath} location, so the existing, unchanged
DetectCrossLocationDrift (it only ever groups by skill name, never by location) also catches drift across
repos for free — the same "skill X is byte-identical across N repos except M outliers" signal Wave 2 gives
you within one repo, now at workspace scale:
# clone the repos you want to audit yourself first, e.g.:
# gh repo clone myorg/service-a ~/audit/service-a
# gh repo clone myorg/service-b ~/audit/service-b
agenteval skills scan-workspace ~/audit --write-baseline --format json -o report.json
Same option surface as scan --repo (--format, --fail-on-noncompliant, --write-baseline,
--baseline-root, --check-baseline) — deliberately a separate verb rather than a --workspace flag bolted
onto scan, to avoid colliding with Mission Control's own, unrelated --workspace concept. Defaults to its
own baseline root (.agenteval/skills-baselines-workspace, not scan's .agenteval/skills-baselines) so a
later plain scan --write-baseline can't accidentally get diffed against a wildly larger workspace-scale
snapshot (or vice versa) — pass --baseline-root explicitly if you genuinely want one shared ledger.
Two known limitations, disclosed here rather than discovered the hard way:
baseline diff/historytrack only one location per skill name, even when the SAME scan legitimately produced several (Wave 2's own pre-existing shortcut,GroupBy(Name).First(), chosen to avoid crashing on duplicate names rather than to build a full per-location history — see the code's own remarks). At--reposcale this rarely bites; at workspace scale, where the same skill name in N repos is the expected case, it meansbaseline diff/historycan silently miss drift in every repo except whichever one sorts first. The live scan-timeCrossLocationContentDriftfinding (re-run each time) does NOT have this limitation — it checks every location, every scan. For genuine per-repo longitudinal tracking, treat each repo's own baseline separately (scan --repo <one-repo> --write-baseline) rather than relying on the workspace ledger.- A skill name shared by two unrelated repos looks identical to real drift.
DetectCrossLocationDrifthas no concept of "these repos are unrelated" — two different teams both naming a skillcode-reviewwith legitimately different content will produce the sameMedium-severity finding as an actual rug-pull. The severity and "verify this is intentional" wording already account for this ambiguity; expect more such findings to triage as workspace size grows.
3 — Skill-injection red-team attack + run_skill_script governance
AgentEval.RedTeam.Attacks.SkillInjectionAttack (OWASP LLM01, in Attack.All — the framework now ships 14
attack types / 264 probes) red-teams two surfaces:
- a malicious skill's description, spliced into the system prompt via
{skills}— a higher-trust position than a retrieved document (InjectionSurface.SkillInstruction); read_skill_resourceoutput (InjectionSurface.SkillResource).
It reuses the shipped IndirectInjectionRubric judge rather than inventing a new mega-judge — but a live
calibration found the reused rubric does not clear the promotion bar on this new surface: 4 missed attacks
out of a 52-case, both-directions gold set
(AgentEval.Guardrails.Judges.Rubrics.SkillInjectionGoldSet), IsInlineReady == false. It ships
shadow-only for skills — the judge's verdict is advisory, printed for visibility, but the actual
pass/fail is keyed on real tool-call evidence (did the agent call the forbidden tool?), never on an
uncalibrated judge's opinion. This is documented explicitly, not silently promoted; see
Gatekeeper — gate reference for the general calibration bar every judge is
held to.
Governance for the other half of the surface — actually executing run_skill_script code — is deterministic
and has no calibration debt:
SkillScriptExecutionGate(IToolGate) — an allowlist gate at the function-invocation seam. Matches by argument value (every string argument, plus pairwise"/"-joins), not by a specific key name, so it survives a MAF parameter rename.SkillScriptApprovalGate(IToolApprovalGate) — a human-in-the-loop trust allowlist, sitting before the function-invocation seam (MAF's own approval layer).
Composition-ordering note: MAF's own approval pause runs before the FICC seam. If you leave
run_skill_script on its human-approval-by-default posture, the approval gate — not
SkillScriptExecutionGate — is what stops an unlisted script, and gate.tool.* will record zero blocks.
To demonstrate (or test) the deterministic gate specifically, auto-approve at the MAF layer
(AgentSkillsProviderOptions.DisableRunSkillScriptApproval = true) so the call reaches the FICC seam — see
Run 6 of the sample for the full, live-verified walkthrough.
4a/4b — Skill Health & Security Index + hash-pin drift
AgentEval.Skills.SkillSecurityIndex joins three independently-produced signals into one composite 0–100
score:
| Axis | Source |
|---|---|
| Compliance | Phase 2's SkillComplianceReport |
| Efficiency | Phase 1's SkillDisclosureEfficiencyMetric (optional) |
| Security | Phase 3's real attack outcome + hash-pin drift findings |
A missing axis is never fabricated as perfect — the score is the mean of only the axes actually supplied,
and SkillSecurityIndexResult.AxesMeasured tells you how many of the three went in.
var indexResult = SkillSecurityIndex.Compute(new SkillSecurityIndexInputs(complianceReport, efficiencyResult: null, securityOutcome));
Console.WriteLine($"Skill Security Index: {indexResult.Score:F0}/100 ({indexResult.AxesMeasured}/3 axes measured)");
Hash-pin drift detection guards against a "rug pull" — a skill's description or resources silently
changing after it was reviewed and trusted. AgentEval.Guardrails.ManifestFingerprint (SHA-256) +
ManifestDriftDetector are generic, MAF-free primitives — the same pair also backs Gatekeeper's
McpToolDescriptionPoisoningGate (see gate reference). For skills
specifically:
var baseline = SkillManifestBaseline.Capture(manifests, notes: "reviewed and approved 2026-07-16");
await baseline.SaveAsync("skills-baseline.json");
// ... later, before every run:
var current = await baseline.LoadAsync("skills-baseline.json");
var findings = SkillManifestPoisoningGate.CheckDrift(latestManifests, current);
// findings.Any(f => f.Kind == ManifestDriftKind.Changed) => the skill changed since it was approved
This is trust-time drift detection (JSON-persisted, mirrors the RedTeam baseline/diff CI pattern), not a runtime gate that scores every turn — it fires when you re-scan, e.g. in CI before deploying an updated skill pack.
Reachable from the CLI, not library-only: agenteval skills scan <path> --save-manifest-baseline <file> captures the pin (reusing the compliance scan's already-computed structural fingerprint — no
re-hashing); a later agenteval skills scan <path> --manifest-baseline <file> checks against it and reports
a changed skill as a High-severity ManifestChangedSinceBaseline finding, gated by the existing
--fail-on-noncompliant (no separate fail flag needed). A SINGLE pinned file, deliberately distinct from
--write-baseline's multi-snapshot ledger above — commit it like the RedTeam CI baseline:
agenteval skills scan ./skills --save-manifest-baseline skills-baseline.json --baseline-note "reviewed 2026-07-17"
# ... later, in CI, before deploying an updated skill pack:
agenteval skills scan ./skills --manifest-baseline skills-baseline.json --fail-on-noncompliant
Works the same way with --repo/scan-workspace — a skill name shared across locations is deduplicated the
same GroupBy-first way baseline diff/history already tolerate it (see §2's known limitations above); the
dictionary key is lowercased before grouping specifically so a rug-pull that also re-cases the skill's name:
can't evade detection by looking like an unrelated new-skill-appeared / old-skill-vanished pair instead of a
Changed skill.
Two more disclosed limitations: (1) --fail-on-noncompliant is the only gate — there is no dedicated
--fail-on-manifest-drift — so a repo carrying unrelated pre-existing High findings can't isolate "block only
on a rug-pull" without also fixing/waiving those. (2) --format json's Rule/Severity fields serialize as
raw integers, not names (pre-existing behavior for every compliance rule, not new to this feature) — a CI
script filtering JSON output by rule name (e.g. jq 'select(.Rule=="ManifestChangedSinceBaseline")') will
silently match nothing; key off the process exit code, or use --format markdown/console for
human-readable rule names, until the shared renderer gains enum-name serialization.
5 — Detecting MAF's silent skill-discovery exclusions
MAF's own AgentFileSkillsSource.GetSkillsAsync() silently excludes a skill folder from discovery
entirely whenever its SKILL.md fails certain GA rules (invalid name: characters, consecutive hyphens, a
directory-name mismatch, or a missing/too-long description:) — the folder just vanishes from the scan with
no error and no finding, reported as if it never existed. Confirmed against the live 1.13.0 assembly: the
exclusion is recursive (a malformed SKILL.md at any level blanks its entire subtree, not just itself),
and MAF's own container-mode discovery is bounded to exactly 2 directory levels deep.
agenteval skills scan now runs a second, independent raw directory walk (AgentEval.Core.Skills.RawSkillDirectoryScanner)
mirroring MAF's discovery convention, reconciles it against what GetSkillsAsync() actually returned, and for
anything present-on-disk-but-silently-excluded, re-parses the SKILL.md directly and reports a
SkillExcludedFromDiscovery (High) finding — worded to make clear the skill is non-functional as authored,
not merely non-compliant. SkillComplianceReport.Coverage.SilentlyExcludedCount surfaces the count prominently
in every renderer. A compatibility field over 500 characters, which otherwise makes GetSkillsAsync() throw
and would crash the whole scan, is caught and reported as one clean finding instead.
What's not built yet
Phase 4c (skill fuzzing via the transform/codec pipeline, a canary-skill honeypot, skill-name typosquatting,
load-storm-as-denial-of-wallet) was deprioritized this session in favor of shipping 4a/4b and the exclusion-
detection fix (§5) with full rigor. Wave 3a (filesystem multi-repo scan, scan-workspace) shipped — see §2
above. Wave 3b (a live, API-driven org-wide scan reaching repos you haven't cloned — scan-org — plus live
upstream verification against a skill's declared source URL) is still gated on a security/credential-scope
review not yet held, since it needs a real GitHub/GitLab API client and token handling that scan-workspace
deliberately avoids — see strategy/TODO.md (local-only) for the up-to-date backlog.
Related
- Assertions — the general fluent-assertion API
SkillUsageAssertionsextends. - Red Team Security — the general attack framework
SkillInjectionAttackplugs into. - Gatekeeper — gate reference — the general gate/judge calibration bar, and
the two other gates (
MemoryWritePoisoningGate,McpToolDescriptionPoisoningGate) that reuse the same patterns introduced here. - Architecture — implementation-level detail (file layout, MAF API constraints discovered this session).