Repository navigation
Enforce .agent.md tools: policy at runtime for copilot-sdk execution - #596
Conversation
Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com>
…arsing Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com>
…olicy Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com>
.agent.md tools: during SDK evaluation.agent.md tools: policy at runtime for copilot-sdk execution
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical policy-resolution and canonical tool-name findings, plus moderate state-handling and coverage issues, block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
internal/execution/copilot.go:417
- The policy is wired into
ResumeSessionConfighere, but the new tests only assert the create-session path. A regression in resumed turns would re-expose tools despite the acceptance requirement for resumed sessions. Add a test that resumes with a policy and checksAvailableToolsplus rejection of an undeclared permission request.
OnPermissionRequest: permRequestCallback,
AvailableTools: availableTools,
internal/orchestration/agent_graders.go:76
- When
fm.Toolsis nil because a valid agent omittedtools:, this branch returns nil.CopilotEngineonly serializesToolPolicyModefor a non-nil policy, so the session digest andresults.jsonomitunrestricted, despite the documented effective-policy recording and the tri-state's third state. ReturnNewToolPolicy(nil)for this valid case while reserving nil for no resolved agent or a resolution failure.
// resolveAgentPath finds the first .agent.md file in the given skill directories.
// Returns empty string if no agent file is found.
- Files reviewed: 10/11 changed files
- Comments generated: 4
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved critical and moderate findings remain across asset references, policy matching, propagation, and result surfacing.
Review details
Suppressed comments (11)
Previously missed (1) — in code that hasn't changed since the last review.
internal/orchestration/runner.go:342
- This lookup can leave
r.toolPolicystale: on the second pass of a baseline runSkillPathsis emptied, so this branch is skipped butbuildExecutionRequeststill reuses the policy resolved during the skills-enabled pass. It also scans unfiltered paths even though requests useFilteredSkillPaths, so--no-skillscan enforce a disabled agent. Clear the field before resolving and use the filtered paths here.
This issue also appears on line 351 of the same file.
internal/execution/toolpolicy.go:129
- This canonicalizer strips
builtin:,mcp:, andcustom:prefixes, butaugmentGradersFromAgentpasses the raw declaration toallow_only, whose matcher compares it directly with the rawToolCall.Name. For example,tools: [builtin:bash]is allowed by the runtime asbashbut an ordinarybashevent is rejected by the implicit grader. Either remove this prefix normalization or apply the same canonicalization to grader declarations and recorded calls.
func canonicalToolName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if idx := strings.Index(name, ":"); idx > 0 {
switch name[:idx] {
case "builtin", "mcp", "custom":
name = name[idx+1:]
}
}
internal/execution/toolpolicy.go:215
- The custom-agent guide defines
fileRead,fileWrite, andrunCommandas validtools:entries, but these native permission variants are canonicalized here asread,write, andbash. An agent using the documented names will therefore have its otherwise allowed file/shell requests rejected before execution, so runtime enforcement does not match the existing frontmatter/grader contract. Define the canonical aliases consistently (and test these variants) or update the documented contract before enabling this policy.
case *copilot.PermissionRequestRead:
return "read", true
case *copilot.PermissionRequestWrite:
return "write", true
case *copilot.PermissionRequestShell:
internal/execution/toolpolicy.go:106
- This list is passed directly to the SDK as
AvailableTools, but the policy map has already lowercased and stripped prefixes from every declaration. That turns documented identifiers such ascodeSearch,fileRead, andrunCommandinto different names (codesearch,fileread,runcommand) before the SDK filters its tools, which can hide an allowed tool even though the permission comparison is case-insensitive. Keep the original declared identifiers forAvailableToolsand use canonicalized values only for comparisons.
names := make([]string, 0, len(p.allowed))
for n := range p.allowed {
names = append(names, n)
}
sort.Strings(names)
internal/execution/toolpolicy.go:200
- The SDK MCP permission request carries the server separately from the tool name, while the SDK exposes MCP tools as
<server-key>-<tool-name>(and recommendsmcp:<server-key>-<tool-name>forAvailableTools). Droppingreq.ServerNamemeans a declaration such astools: [github-list_issues]is reduced tolist_issuesand rejected, while the SDK filter also receives an unqualified name. Compose the server and tool using one canonical contract before matching and populating the allow-list.
case *copilot.PermissionRequestMCP:
return canonicalToolName(req.ToolName), true
case *copilot.PermissionRequestHook:
internal/models/outcome.go:277
- These fields reach
results.jsonthroughSessionDigest, but the existing--session-logpath still writes only aggregateSessionCompleteDatatotals and never emits the per-run policy mode or denials. The issue and PR description require the effective policy and every denial in session logs as well; add those fields to the session events or log the run digest during execution.
// ToolPolicyMode is the effective .agent.md `tools:` runtime policy
// applied to this session: "unrestricted", "deny_all", or "allow_list".
// Empty when no .agent.md tool policy was resolved for the run.
ToolPolicyMode string `json:"tool_policy_mode,omitempty"`
// ToolPolicyDenials records every tool-execution attempt denied by an
// active tool policy across all turns of this session (initial,
// follow-up, and responder-driven alike).
ToolPolicyDenials []ToolPolicyDenial `json:"tool_policy_denials,omitempty"`
internal/models/outcome.go:277
- These fields are added to the canonical session digest, but
internal/webapi.mapSessionDigeststill constructsSessionDigestResponsewith only the legacy fields, and the web client type is unchanged. Dashboard/API consumers therefore droptool_policy_modeandtool_policy_denials, so the new policy result is unavailable outside rawresults.json; add the fields to that DTO/mapping and client representation.
ToolPolicyMode string `json:"tool_policy_mode,omitempty"`
// ToolPolicyDenials records every tool-execution attempt denied by an
// active tool policy across all turns of this session (initial,
// follow-up, and responder-driven alike).
ToolPolicyDenials []ToolPolicyDenial `json:"tool_policy_denials,omitempty"`
internal/orchestration/agent_graders.go:72
- An agent with a valid
.agent.mdbut notools:key is treated asnilhere, soExecutionResponse.ToolPolicyModestays empty and the session digest cannot report the requiredunrestrictedstate; it is indistinguishable from a run with no agent policy. Keepnilonly forfm == niland returnexecution.NewToolPolicy(fm.Tools)for the omitted-key case (Active()already makes it a no-op).
if fm == nil || fm.Tools == nil {
return nil
}
return execution.NewToolPolicy(fm.Tools)
internal/orchestration/runner.go:351
resolveAgentPathreturns the first.agent.mdit encounters and this call does not usespec.SkillName, whereasbuildSkillSystemMessageselects the definition by name. With multiple configured agent directories (or multiple agent files), the runtime policy can therefore come from a different agent and allow or deny the wrong tools. Resolve the path for the selected agent before assigning its policy.
spec.Graders = augmentGradersFromAgent(spec.Graders, agentPath, fm)
r.toolPolicy = resolveToolPolicy(fm)
internal/orchestration/runner.go:1530
- The new field is exercised only by direct
CopilotEnginetests; there is no orchestration test that sends a static follow-up or responder reply and verifies the policy is carried into that request and that a later-turn denial is merged intoSessionDigest. This is the path required to enforce and surface violations beyond the initial turn, so add regression coverage for it.
ToolPolicy: r.toolPolicy,
internal/orchestration/runner.go:351
- This resolution scans the raw
spec.Config.SkillPaths, whilebuildExecutionRequestlater usesFilteredSkillPathsand can setNoSkillswhen the agent directory is disabled (for example viadisabled_skills: ["*"]). In that case the.agent.mdis not exposed to the SDK, but itstools:policy is still attached to every request and can deny the fallback agent's tools. Resolve from the same effective skill paths, or skip the policy when the target agent is disabled.
r.toolPolicy = resolveToolPolicy(fm)
- Files reviewed: 10/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
Shayne Boyer (spboyer)
left a comment
There was a problem hiding this comment.
Reviewed runtime enforcement of custom-agent tool policies at bb42abaf with two independent review passes.
No new, non-duplicate findings. Previously reported blockers remain at this head, so changes are still requested. See the existing review; no duplicate inline comments are added.
…aseline-reset review findings Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved policy mismatches, resolution gaps, missing result/UI propagation, asset issues, and incomplete integration coverage block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
internal/orchestration/agent_graders.go:72
- For an existing
.agent.mdwhosetools:key is omitted, this returns nil, soCopilotEngineleavesExecutionResponse.ToolPolicyModeempty andSessionDigestomits the effectiveunrestrictedmode. That loses the tri-state result and conflates it with a run with no agent; keep nil only whenfmis nil and returnNewToolPolicy(fm.Tools)for an agent frontmatter.
internal/execution/copilot.go:417
- The new tests only capture the initial
SessionConfigon a mocked client and invoke the callback directly; they do not exercise the changedResumeSessionConfigbranch or a real Copilot session. That cannot catch an SDK regression whereAvailableToolsis ignored or a resumed session installs a different callback, while the issue's acceptance criteria explicitly require resumed-session and live bash/web-fetch coverage. Add a policy-specific resume test and a gated live integration test.
OnPermissionRequest: permRequestCallback,
AvailableTools: availableTools,
internal/execution/copilot_test.go:287
- The new tests exercise the wrapper with mock permission requests, but none drives a real SDK session/model through
AvailableToolsand the built-in URL/shell paths. That leaves the actual tool-name contract (includingweb_fetchand the read aliases) unverified; the issue's acceptance criteria explicitly require a live read-only integration scenario. Add a gated integration test/CI job or document and track the missing acceptance criterion separately.
func TestCopilotExecute_ToolPolicyDenialFailsRun(t *testing.T) {
ctrl := gomock.NewController(t)
clientMock := newClientMock(ctrl)
sessionMock := NewMockCopilotSession(ctrl)
sourceDir := t.TempDir()
declared := []string{"read"}
policy := NewToolPolicy(&declared)
var capturedConfig *copilot.SessionConfig
clientMock.EXPECT().CreateSession(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, cfg *copilot.SessionConfig) (CopilotSession, error) {
capturedConfig = cfg
return sessionMock, nil
})
sessionMock.EXPECT().Disconnect()
clientMock.EXPECT().DeleteSession(gomock.Any(), "session-1")
sessionMock.EXPECT().On(gomock.Any()).Times(3).Return(func() {})
sessionMock.EXPECT().SendAndWait(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, _ copilot.MessageOptions) (*copilot.SessionEvent, error) {
// Simulate the model attempting an undeclared bash call mid-turn.
_, err := capturedConfig.OnPermissionRequest(&copilot.PermissionRequestShell{FullCommandText: "curl example.com"}, copilot.PermissionInvocation{})
require.NoError(t, err)
return &copilot.SessionEvent{}, nil
})
internal/models/outcome.go:277
- These fields are added to the results model, but
internal/webapi.mapSessionDigestandweb/src/api/client.ts/SessionDigestCardstill drop and cannot display them. Thus policy mode and denials disappear from the dashboard even though this PR adds result metadata and updates the dashboard artifact. Update the API/UI mapping, or explicitly scope this feature to raw results and logs.
// ToolPolicyMode is the effective .agent.md `tools:` runtime policy
// applied to this session: "unrestricted", "deny_all", or "allow_list".
// Empty when no .agent.md tool policy was resolved for the run.
ToolPolicyMode string `json:"tool_policy_mode,omitempty"`
// ToolPolicyDenials records every tool-execution attempt denied by an
// active tool policy across all turns of this session (initial,
// follow-up, and responder-driven alike).
ToolPolicyDenials []ToolPolicyDenial `json:"tool_policy_denials,omitempty"`
- Files reviewed: 10/11 changed files
- Comments generated: 4
- Review effort level: Lite
Align native tool filters, aliases, task-specific agent selection, graders, session logs and dashboard metadata. Add resumed/live SDK and later-turn regressions; publish additive schema 1.3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Shayne Boyer (spboyer)
left a comment
There was a problem hiding this comment.
Reviewed ec4a1a7 against current main, including all historical feedback and two independent review passes. The policy, selected-agent resolution, canonical names/native filters, resumed and later-turn behavior, schema 1.3, session logs, and dashboard propagation are addressed. No remaining actionable findings after deduplication. Full Go tests/vet/short suite and lint pass; live SDK initial/resumed read-only regression passes; dashboard build/lint, 58 Chromium tests, and docs build pass. All original inline threads are resolved. This supersedes my earlier changes-requested review; it does not waive required CI, strict up-to-date main, or independent latest-push/code-owner approval.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Two critical enforcement issues and one agent-discovery defect remain unresolved; the schema default also needs alignment.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (3)
Resolved since last review (5)
This resolves the policy from the first.agent.mdfound in the eval-level skill paths, but… URL permission requests are always canonicalized tofetch. If an agent declares the… The public custom-agent guide usestools: [codeSearch, fileRead, runCommand], but this alias… Both an omittedtools:key and a failedLoadAgentDefinitionbecomenilhere. A malformed or… The runtime policy canonicalizesreadFile/writeFiletoread/write, but…
Preserve source namespaces, make denial-only responses fatal, discover nested agents beside root agents, and align embedded schema defaults. Add focused regressions for each review finding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Shayne Boyer (spboyer)
left a comment
There was a problem hiding this comment.
Re-reviewed the new head c53e73b after the post-push review. Source-qualified permission matching, fatal denial-only responses on all turn paths, nested target discovery beside unrelated root agents, and embedded schema default alignment are fixed with regressions. All 11 inline threads are resolved; no new actionable findings remain after deduplication. Full Go tests/vet/short suite and lint pass again; repeated live SDK initial/resumed read-only test and docs build pass. Previous dashboard build/lint and 58 Chromium tests remain applicable (no frontend changes in this follow-up). Required CI and independent latest-push/code-owner approval still apply; no bypass requested.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical findings remain: a nil permission request can panic, and the integration test file does not compile.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60dcd98b-99f2-4bac-833f-e6d1c0607512
Shayne Boyer (spboyer)
left a comment
There was a problem hiding this comment.
All existing review findings are fixed in 0f3cd51. Nil and typed-nil requests now fail closed, the tri-state tests compile, and full Go tests and lint pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60dcd98b-99f2-4bac-833f-e6d1c0607512
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60dcd98b-99f2-4bac-833f-e6d1c0607512
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60dcd98b-99f2-4bac-833f-e6d1c0607512
Shayne Boyer (spboyer)
left a comment
There was a problem hiding this comment.
Re-reviewed the current conflict-resolved head. The fail-closed tool-policy fixes are preserved, current main changes are integrated, all review threads are resolved, and full Go tests and lint pass.
| resolvedSkillPaths := r.taskSkillPaths(tc) | ||
| noSkills := spec.Config.AllSkillsDisabled() | ||
| _, fm, err := r.resolveTaskAgent(tc) | ||
| if err != nil { | ||
| return nil, err |
| execution.IsSkillAvailable(resolvedSkillPaths, spec.SkillName), | ||
| MCPServers: convertMCPServers(spec.Config.ServerConfigs, spec.MCPMocks, r.cfg.SpecDir()), | ||
| FirstEventTimeout: r.firstEventTimeout(tc), | ||
| ToolPolicy: resolveToolPolicy(fm), |
| if err != nil { | ||
| return "", nil, fmt.Errorf("resolving agent working directory: %w", err) | ||
| } | ||
| return execution.ResolveAgentDefinition(append([]string{cwd}, r.taskSkillPaths(tc)...), r.cfg.Spec().SkillName) |


Summary
Enforce the selected custom agent's
.agent.mdtools:declaration for Copilot SDK evaluations. Omitted tools recordunrestricted, an empty list meansdeny_all, and populated lists meanallow_list. Denied attempts fail the run even when the final answer is otherwise correct or an engine reports no error text.Implementation
mcp:<server>-<tool>andcustom:<tool>declarations. Permission keys preserve these namespaces.Validation
go vet -p 2 ./..., fullgo test -p 2 ./..., short suite, and golangci-lint (0 issues), repeated after post-push feedback fixes.Boundaries
This restricts tool capabilities, not what an allowed tool can access. Hooks/transcripts may omit source metadata: explicit custom/MCP declarations match those literal names, with source isolation supplied by SDK native filtering. Explicitly supplied source metadata is preserved. A permitted subagent launcher is not a separate subagent sandbox. Source-aware identifiers retain SDK spelling; exact-name checks do not grant wildcard patterns.
Closes #585