Skip to content

Add skill-runtime evals: execute an exported SKILL.md in a real runtime - #70

Open
Karim Mehalebi (karimad) wants to merge 6 commits into
microsoft:mainfrom
karimad:add-skill-runtime-evals
Open

Add skill-runtime evals: execute an exported SKILL.md in a real runtime#70
Karim Mehalebi (karimad) wants to merge 6 commits into
microsoft:mainfrom
karimad:add-skill-runtime-evals

Conversation

@karimad

@karimad Karim Mehalebi (karimad) commented Aug 24, 2026

Copy link
Copy Markdown

Summary

Addresses #55 (and the feedback that closed #53): evals/skillbuilder/ only scores the builder's proposed plan text — tool mentions, structure — never whether an actually-exported SKILL.md gets loaded and executed correctly by a real target runtime. This adds that missing layer: generate → export → load into a runtime → execute → score the real resulting behavior.

Deliberately uses only what's already required for the rest of this eval suite — a signed-in Copilot CLI and the already-vendored @github/copilot-sdk. No new dependency, no new credential.

What's in evals/skill-runtime/

  • fixtures/regenerate.ts + fixtures/github-issue-triage-agent-skill/SKILL.md — a real, already-built agent-skill artifact. Ships as a static, checked-in fixture rather than being regenerated on every run, so a runtime-eval failure points at the runtime, not builder variance.
  • mocks/gh — a real, checked-in mock gh executable. Actually simulates GitHub-side filtering, not just fixed replies: issue list only returns the clean, intended result when the invocation's flags genuinely filter correctly; otherwise it returns a noisier set including issues a correct filter would have excluded (#300 already assigned, #310 wrong label). issue view returns the correct data per requested issue number.
  • allowed-tools.ts — shared parser and matcher for a fixture's allowed-tools frontmatter, used by both the enforcement layer and the scoring safety net so they can't drift apart. Rejects commands containing shell metacharacters (;, &&, |, backticks, $(...), redirection) so an allowed prefix can't be used to smuggle in a second, unchecked command; prefix matches require a token boundary, not just a raw string prefix.
  • allowed-tools.test.ts — deterministic unit tests for the pattern parser and matcher, including the shell-injection-via-prefix-match case (see bug Bump fast-uri from 3.1.3 to 3.1.4 #3 below).
  • bash-tool.ts — the one capability the runtime session gets: a custom Bash tool that enforces the fixture's own declared allowed-tools before executing anything (a command outside the declared patterns is refused before it ever reaches /bin/sh, not just flagged after the fact), runs with a minimal environment (no inherited host secrets/tokens, no real PATH), and records every invocation (and every refusal, separately) for scoring.
  • run.ts — spins up a fresh Copilot session (separate from any builder session), skillDirectories pointed at the fixture, sends a task prompt, and scores the real resulting invocations against the rubric. Cleans up its temp dirs unless --keep is passed.
  • scenario.ts / scenarios.ts / score.ts — the type/rubric/scoring plumbing, same shape as evals/skillbuilder/.
  • evals/README.md — new "Skill runtime evals" section documenting the lifecycle, rubric, security model, current coverage, and how to add a scenario.

Real bugs found and fixed while building this

The ones with lasting behavioral/security consequences (see commit history for the rest — mock-log substring matching, an MCP-bypass fix, a temp-dir leak, a hardcoded mock issue number):

  1. Scoping availableTools to just ["Bash"] silently disabled every built-in — including whatever loads skills from skillDirectories. The skill was never actually being read.
  2. Security: the Bash tool originally spread the full host process.env/PATH into the child shell with no gating beyond a mocked PATH prefix — an off-spec SKILL.md could reach a real binary with real credentials/network access. Fixed by enforcing allowed-tools before execution and dropping the inherited environment entirely.
  3. Security: that enforcement itself matched declared patterns via a raw String.startsWith against the full command string, which was then handed unmodified to /bin/sh -c. A command like gh issue comment 214 --repo x --body "y" && rm -rf $HOME still starts with the allowed prefix gh issue comment, so the injected second command executed unchecked. Fixed by rejecting any command containing shell metacharacters (chaining/piping/substitution/redirection) before the prefix check, and by requiring prefix matches to land on a token boundary (so gh issue commentXYZ no longer matches the gh issue comment prefix). Covered by new unit tests in allowed-tools.test.ts.

Test plan

  • npm run typecheck — clean
  • npm test — includes 7 new deterministic unit tests in allowed-tools.test.ts covering pattern parsing, prefix/exact matching, and rejection of shell-metacharacter injection attempts
  • npm run eval:skill-runtime — multiple consecutive full-suite passes across all rounds of fixes
  • Directly verified the Bash tool's enforcement in isolation: a curl exfiltration attempt outside the declared allowed-tools is refused before /bin/sh ever runs (never touches the network); a declared gh command still proceeds to execution normally; a chained command appended to an allowed prefix (e.g. gh issue comment 214 ... && rm -rf $HOME) is now refused rather than executed
  • Verified the mock's unfiltered branch actually returns the two distractor issues, and that a correct run avoids all of #220/#300/#310 while acting only on #214
  • Confirmed --keep preserves temp dirs (and denied-attempt logs) for inspection; confirmed their absence otherwise

Related: #55, #53

Addresses the gap in microsoft#55 and adilei's microsoft#53 feedback: evals/skillbuilder/ only
scores the builder's proposed PLAN TEXT (tool mentions, structure) — it never
actually generates -> exports -> loads -> executes a skill in a target runtime
and scores the resulting behavior.

Adds evals/skill-runtime/, using only what's already required for the rest of
this eval suite (a signed-in Copilot CLI, the already-vendored
@github/copilot-sdk) — no new dependency, no new credential, no `claude` CLI:

- fixtures/regenerate.ts + fixtures/github-issue-triage-agent-skill/SKILL.md —
  a real, already-built "agent-skill" artifact (ships as a static fixture, not
  regenerated per run, so a runtime-eval failure points at the runtime layer,
  not builder variance — same principle the rest of this suite already follows).
- mocks/gh — a real mock `gh` executable (checked in, mirrors the existing
  evals/mocks/*.html pattern for a CLI instead of a web page). Returns two
  issues: #214 (unassigned bug, not yet triaged) and #220 (unassigned bug,
  already labeled needs-info) — a correct run must act on #214 only.
- bash-tool.ts — the one capability the runtime session gets: a custom "Bash"
  tool scoped to a mocked PATH, so every invocation is captured for scoring.
- run.ts — generates a fresh Copilot session (skillDirectories pointed at the
  fixture, MCP tools excluded so the built-in GitHub connector can't bypass the
  mock via the real GitHub API), sends the task, and scores the REAL resulting
  gh invocations against the rubric.

Two real infra bugs found and fixed while building this (left as inline
comments in run.ts): scoping `availableTools` to just "Bash" silently disabled
every built-in including whatever loads skills from `skillDirectories`; and
Copilot CLI's built-in GitHub MCP connector resolves repos against the real
GitHub API, bypassing the shell (and any shell-based mock) entirely unless MCP
tools are explicitly excluded.

Verified: 3/3 consecutive runs pass, gh log shows the exact expected sequence
(list -> comment 214 with the skill's fixed text -> label 214 needs-info,
#220 correctly skipped).
…, tool-scope

Correctness:
- score.ts: gh-log matching now requires an exact whitespace-delimited argv
  token, not a raw substring — a call touching issue 2140 or 1214 could
  previously satisfy a check written for 214 (and symmetrically false-fail a
  forbiddenGhCalls check for 220 against a line containing 1220), undermining
  the exact thing this eval verifies. forbiddenInCommands stays substring-based
  intentionally (documented why: vendor-name detection needs to catch a prefix
  like "workiq" inside "workiq_search_chats", which token-exact matching would
  miss) — same trade-off evals/skillbuilder/score.ts already accepts.
- mocks/gh: `issue list` now actually simulates filtering instead of ignoring
  every flag. Only returns the clean #214/#220 pair when the invocation asked
  for `--label ... bug` + `no:assignee`; otherwise returns a noisier set
  including two new distractors — #300 (already assigned) and #310 (wrong
  label) — that a correctly-filtering skill must never touch. Strengthens the
  "runtime conformance" claim: a skill that dropped its own filtering logic
  now visibly fails instead of silently passing.

Maintainability:
- run.ts: the 3 mkdtempSync temp dirs per scenario run are now cleaned up in a
  finally block unless --keep is passed — previously always leaked into /tmp.
  fixtures/regenerate.ts: same fix for its own temp dirs.
- score.ts: new checkAllowedTools assertion parses the fixture's own
  `allowed-tools` frontmatter and verifies every MUTATING Bash command (comment/
  edit/create/close/...) matches a declared prefix — a regression in tool-gating
  would now be caught. Scoped to mutating commands only, not every command:
  gating on read-only reconnaissance (e.g. an occasional `gh repo view` sanity
  check before triaging) would fail the suite on harmless model variance rather
  than a real regression — same read-vs-mutating distinction this project's own
  catalogues already draw ("Read tools are auto-approved; send/create/update/
  delete need approval").

Minor:
- fixtures/regenerate.ts: comment on the renameSync call documenting the
  assumption that `exportRoot` has exactly one child (the freshly-exported
  skill dir) — true because it's a dir we just mkdtemp'd, but worth stating.

Verified: 3/3 consecutive full-suite passes after all fixes; confirmed the
mock's unfiltered branch actually returns the distractor issues; confirmed
--keep still preserves temp dirs and their absence otherwise.
… gaps

Security (real gap, not just hardening):
- bash-tool.ts previously spread the full process.env into the child shell and
  only prepended a mock bin dir to PATH, leaving the real PATH appended after
  it. excludedTools/enableHostGitOperations only restrict Copilot's built-in
  tools, not this custom one — an untrusted or off-spec SKILL.md could reach a
  real binary (curl, real gh, ...) with real env vars and real network access.
- Fixed by making the custom Bash tool actually ENFORCE the fixture's own
  declared allowed-tools before executing anything, not just audit after the
  fact: a command that doesn't match a declared Bash(...) pattern is refused
  (exitCode 126) before /bin/sh ever runs, recorded separately in
  deniedTrace (visible under --keep) so a refusal is never confused with a
  scoring violation. This closes the companion scoring gap too — a real side
  effect via a non-gh command was previously invisible to the rubric; now it
  can't happen at all, because it can't execute.
- Also stopped spreading process.env into the child process entirely (no
  inherited host secrets/tokens even if enforcement were ever bypassed by a
  future change) and narrowed PATH to the mock dir + /usr/bin:/bin only.
- New evals/skill-runtime/allowed-tools.ts: shared parser/matcher used by both
  the enforcement layer (bash-tool.ts) and the redundant post-hoc safety-net
  check (score.ts), so they can't drift apart. Also fixes a real parsing gap:
  the old regex only matched entries ending in a literal `*)`; an entry like
  `Bash(gh issue view)` with no trailing wildcard was silently dropped from
  enforcement with no warning. Now parsed as an exact-match pattern instead.

Correctness:
- run.ts: tempDirs.push(...) previously ran only after all three mkdtempSync
  calls succeeded — if the 2nd or 3rd threw, the earlier-created dirs were
  never registered for cleanup and leaked. Now each dir is pushed immediately
  after its own creation.
- mocks/gh: `issue view` always echoed hardcoded issue #214 regardless of the
  actual issue number requested ($3). Fixed to return the correct mock issue's
  data per number (214/220/300/310), or a "not found" error for anything else.

Verified: 3/3 consecutive full-suite passes with enforcement active. Directly
tested the Bash tool's enforcement in isolation: a curl attempt outside the
declared allowed-tools is refused before /bin/sh ever runs (stays in
deniedTrace, never touches trace or the network); a declared gh command still
proceeds to execution normally.
Follows the same per-suite section pattern already used for the builder,
skillbuilder, and sensitive harnesses: what it guards and why, how a run
works, the rubric, current coverage, and how to add a scenario. Fills a real
gap — the suite existed with no doc explaining how someone else (or a future
me) would add a new fixture/mock/scenario to it.
@karimad

Copy link
Copy Markdown
Author

adilei can you please check this pr ?

Karim Mehalebi added 2 commits August 25, 2026 16:12
commandMatchesAny() used a raw string prefix check on the full command,
which was then passed unmodified to /bin/sh -c. An allowed prefix like
`gh issue comment` could be used to smuggle a chained/injected command
via shell metacharacters (&&, ;, |, $(), etc.), defeating the
allowed-tools enforcement. Reject commands containing shell
metacharacters and require prefix matches to land on a token boundary.
Also adds unit test coverage, a configurable Bash timeout, and a
clarifying comment on the unused "label" verb.
sendAndWait() can resolve to undefined (session timeout/close before
any assistant message), but its result was dereferenced unconditionally
for --keep debug logging. Guard the access and report the no-reply case
explicitly instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant