feat: add provider-backed evaluators with TypeSafe Jev

Assisted-By: docker-agent
This commit is contained in:
David Gageot committed 2026-09-22 09:49:38 +02:00
1 parent daf0f15a52
commit d5bfbab6f5
45 files changed
+3732 -67

No files matched your search

+2
View File
@@ -5,3 +5,5 @@ GOOGLE_API_KEY=
GOOGLE_GENAI_USE_VERTEXAI=
MISTRAL_API_KEY=
OPENROUTER_API_KEY=
TYPESAFE_API_KEY=
+290 -30
View File
@@ -136,6 +136,13 @@
"null"
]
}
},
"evaluators": {
"type": "object",
"description": "Named provider-backed assessments, independent of chat models and decision policies.",
"additionalProperties": {
"$ref": "#/definitions/EvaluatorConfig"
}
}
},
"additionalProperties": false,
@@ -1201,182 +1208,182 @@
"type": "array",
"description": "Hooks that run before a tool is executed. Can allow/deny/modify tool calls.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
"$ref": "#/definitions/NonEvaluatorHookMatcherConfig"
}
},
"post_tool_use": {
"type": "array",
"description": "Hooks that run after a tool completes (both success and failure). The result is delivered in tool_response (failed calls carry an is_error flag and any error text). Returning decision=block or exit code 2 stops the run loop after the current tool batch \u2014 useful for circuit-breaker patterns.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
"$ref": "#/definitions/NonEvaluatorHookMatcherConfig"
}
},
"permission_request": {
"type": "array",
"description": "Hooks that run just before the runtime prompts the user to approve a tool call (i.e. when the safety mode and permission rules did not short-circuit the decision). Hooks may auto-allow or auto-deny via hook_specific_output.permission_decision; otherwise control falls through to the interactive confirmation. Tool-matched, like pre_tool_use.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
"$ref": "#/definitions/NonEvaluatorHookMatcherConfig"
}
},
"session_start": {
"type": "array",
"description": "Hooks that run when a session begins. Can load context or setup environment.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"user_prompt_submit": {
"type": "array",
"description": "Hooks that run once per user message, after the user has submitted their prompt and before the first model call of the turn. The submitted text is passed in the prompt field. Hooks can block submission (decision=block / continue=false / exit code 2) or contribute additional_context that is spliced into the conversation as a transient system message for that turn only. Sub-sessions (transferred tasks, background agents) do NOT fire this event because their kick-off message is synthesised by the runtime.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"user_steering_messages_submit": {
"type": "array",
"description": "Hooks that run each time the runtime drains the steering queue and appends the queued user messages to the session \u2014 messages the user submitted while the agent was already working (mid-turn, after the model stopped, or while idle before the first model call). The drained messages are passed in the steering_messages field. Like user_prompt_submit, hooks can block the run (decision=block / continue=false / exit code 2) or contribute additional_context that is spliced into the conversation as a transient system message for the steered turn only \u2014 it is NOT persisted to the session.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"user_followup_submit": {
"type": "array",
"description": "Hooks that run each time the runtime dequeues a follow-up message at the end of a turn and starts a fresh turn for it. Follow-ups are user messages queued for end-of-turn processing (the FollowUp API / queue), distinct from mid-turn steering \u2014 the model sees them as fresh input. The follow-up text is passed in the prompt field. Like user_prompt_submit, hooks can block the run (decision=block / continue=false / exit code 2) or contribute additional_context that is spliced into the conversation as a transient system message for the follow-up turn only \u2014 it is NOT persisted to the session.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"turn_start": {
"type": "array",
"description": "Hooks that run at the start of every agent turn (each model call). Their AdditionalContext is appended as transient system messages for that turn only \u2014 it is NOT persisted to the session, so per-turn signals (date, prompt files) are recomputed every turn instead of bloating message history on every resume.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"turn_end": {
"type": "array",
"description": "Hooks that run once per agent turn when the turn finishes \u2014 the symmetric counterpart of turn_start. Fires no matter why the turn ended: a normal stop, an error, a hook-driven shutdown, the loop detector, or context cancellation. The reason is reported via the hook input's reason field ('normal', 'continue', 'steered', 'error', 'canceled', 'hook_blocked', 'loop_detected'). Observational; output is ignored.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"before_llm_call": {
"type": "array",
"description": "Hooks that run just before each model call (after turn_start has assembled the messages). Use for observability, cost guardrails, or auditing without contributing system messages \u2014 turn_start is the right event for the latter.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"after_llm_call": {
"type": "array",
"description": "Hooks that run just after each successful model call, before the response is recorded into the session and tool calls are dispatched. Receives the assistant text content in stop_response. Failed calls fire on_error instead.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"session_end": {
"type": "array",
"description": "Hooks that run when a session ends. Can perform cleanup or logging.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"pre_compact": {
"type": "array",
"description": "Hooks that run just before the runtime compacts the session transcript into a summary. The trigger is reported in source: 'manual' (user-initiated /compact), 'auto' (proactive threshold), 'overflow' (context-overflow recovery), or 'tool_overflow' (proactive after tool results pushed past the threshold). Hooks may block compaction (decision=block / continue=false / exit code 2) or contribute additional_context that is appended to the compaction prompt \u2014 useful for steering the summary without modifying the agent's instruction.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"subagent_stop": {
"type": "array",
"description": "Hooks that run when a sub-agent (transferred task, background agent, skill sub-session) finishes. Fires against the parent agent's executor so handlers configured on the orchestrator see every child completion. The sub-agent's name is in agent_name and its final assistant message in stop_response.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_user_input": {
"type": "array",
"description": "Hooks that run when the agent needs user input. Can send notifications or log events.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"stop": {
"type": "array",
"description": "Hooks that run when the model finishes responding and is about to hand control back to the user. Can perform post-response validation or logging.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"notification": {
"type": "array",
"description": "Hooks that run when the agent sends a notification (error, warning) to the user. Can send external notifications or log events.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_error": {
"type": "array",
"description": "Hooks that run when the runtime hits an error during a turn (model failures, repetitive tool-call loops). Fires alongside notification (level=error); use this for structured error-only handlers without parsing notification_level.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_max_iterations": {
"type": "array",
"description": "Hooks that run when the runtime reaches its configured max_iterations limit. Fires alongside notification (level=warning); use this for structured handlers (e.g. log to a metrics pipeline) without parsing notification_message.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_agent_switch": {
"type": "array",
"description": "Hooks that run whenever the runtime moves the active agent to a new one (transfer_task, handoff, or the return after a transferred task completes). Receives from_agent, to_agent, and agent_switch_kind in the input. Observational; useful for audit, transcript, and metrics pipelines that track which agent ran which tools.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_session_resume": {
"type": "array",
"description": "Hooks that run when the user explicitly approves the runtime to continue past its configured max_iterations limit. Receives previous_max_iterations and new_max_iterations in the input. Observational; useful for alerting on extended-runtime sessions or for billing/quota pipelines that meter resumes.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"on_tool_approval_decision": {
"type": "array",
"description": "Hooks that run after the runtime's tool approval chain (custom rules / safety mode / hooks / ask) resolves a verdict for a tool call, before the call is executed (allow) or its error response is recorded (deny / canceled). Receives approval_decision (\"allow\" | \"deny\" | \"canceled\"), approval_source (a stable classifier of which step decided), and safety_label (the runtime classifier's safe / destructive / unknown verdict). Observational; gives audit pipelines a single \"who approved what\" record without re-implementing the chain.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"before_compaction": {
"type": "array",
"description": "Hooks that run immediately before a session compaction. The Input carries input_tokens, output_tokens, context_limit, and compaction_reason ('threshold', 'overflow', 'manual'). A hook may veto compaction by setting decision='block' (or exiting with code 2), or supply a custom summary via hookSpecificOutput.summary, in which case the runtime applies that summary verbatim and skips the LLM-based summarization. Be cautious about denying when compaction_reason='overflow': the runtime is recovering from a context-overflow error and a denial there will leave the session unable to make progress.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"after_compaction": {
"type": "array",
"description": "Hooks that run after a successful compaction (a summary was applied to the session). Receives the final summary text in Input.summary alongside input_tokens, output_tokens, context_limit and compaction_reason. Purely observational \u2014 hook output is ignored.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
},
"tool_response_transform": {
"type": "array",
"description": "Hooks that fire between a tool's execution and the runtime's emission/record of the response, with the rewrite reaching event consumers, the persisted session, the post_tool_use hook input, and the next LLM call. A hook may rewrite the tool's textual output by setting hookSpecificOutput.updated_tool_response \u2014 the symmetric counterpart of pre_tool_use's updated_input, applied to tool RESULTS instead of tool ARGUMENTS. The redact_secrets builtin uses this event for the third leg of the redact_secrets feature; custom rewriters can also truncate excessive output, scrub PII, or normalise tool dialects. Tool-matched, like pre_tool_use / post_tool_use.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
"$ref": "#/definitions/NonEvaluatorHookMatcherConfig"
}
},
"tool_input_transform": {
"type": "array",
"description": "Hooks that run before every tool call, ahead of the deterministic approval pipeline (safety mode, permission rules, --yolo) and of tool_guard. Hooks run sequentially, each seeing the preceding rewrite, and may patch tool arguments via hookSpecificOutput.updated_input exactly like pre_tool_use; the approval pipeline and every later hook see the rewritten arguments. Rewrite-only: verdicts belong on tool_guard. Failures follow the hook's on_error policy (default warn; block denies the call). The redact_secrets builtin's argument-scrubbing leg is auto-injected here. Tool-matched, like pre_tool_use; preempt_yolo is rejected because the event always preempts approval.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
"$ref": "#/definitions/NonEvaluatorHookMatcherConfig"
}
},
"tool_guard": {
@@ -1390,7 +1397,7 @@
"type": "array",
"description": "Hooks that run once, just after `docker agent run --worktree` creates a git worktree and before the session starts. They execute inside the new worktree (their working directory is the fresh checkout), and the worktree path, branch, and source repository root are passed in worktree_path / worktree_branch / worktree_source_dir. Use them to prepare the checkout: copy untracked files like .env, install dependencies, warm caches, before the agent begins. A hook may abort the run by blocking (decision=block / continue=false / exit code 2); stdout is surfaced as additional context. Dispatched from the CLI rather than the run loop because the worktree (and the working directory every downstream component captures) must be settled before the runtime and session exist.",
"items": {
"$ref": "#/definitions/HookDefinition"
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
}
},
@@ -1429,7 +1436,7 @@
},
"HookDefinition": {
"type": "object",
"description": "Definition of a single hook command",
"description": "Definition of a lifecycle hook",
"properties": {
"name": {
"type": "string",
@@ -1437,11 +1444,12 @@
},
"type": {
"type": "string",
"description": "Type of hook. 'command' executes a shell command; 'builtin' invokes a named in-process Go function registered by the runtime; 'model' asks an LLM and translates its reply into the hook's native output (used for LLM-as-a-judge pre_tool_use, summarizers, etc., with no Go code). The docker-agent runtime ships these builtins: 'add_context' (context-contributing events: renders Go templates in args against hook input and joins non-empty results with newlines as additional context), 'add_date' (turn_start: today's date), 'add_environment_info' (session_start: cwd, git, OS, arch), 'add_prompt_files' (turn_start: contents of named files looked up in the workdir hierarchy and the home directory; args may include '--depth=<N>' to additionally list, by path only, the same filenames found up to N levels below the working directory), 'add_git_status' (turn_start: `git status --short --branch`), 'add_git_diff' (turn_start: `git diff --stat`, or full diff with args=['full']), 'add_directory_listing' (session_start: top-level entries of cwd), 'add_user_info' (session_start: current OS user and hostname), 'add_recent_commits' (session_start: `git log --oneline -n N`, default N=10, override via args=['<N>']), 'max_iterations' (before_llm_call: hard stop after N model calls; args=['<N>'] required), 'redact_secrets' (tool_input_transform or pre_tool_use / before_llm_call / tool_response_transform: scrubs detected secrets from tool arguments, outgoing chat content, and tool output \u2014 the same builtin handles all three legs and dispatches on the event; the matching agent-level 'redact_secrets: true' flag auto-injects the entries on tool_input_transform, before_llm_call, and tool_response_transform), 'limit_large_tool_results' (tool_response_transform and session_end: stores oversized tool output in a per-session temp file and replaces it with a bounded excerpt; always injected by the runtime), 'safer_shell' (pre_tool_use: deprecated compatibility shim that attaches shell safety classification metadata), 'http_post' (any event: POSTs args[1] to the URL in args[0]), 'snapshot' (session_start, turn_start, turn_end, pre_tool_use, post_tool_use, and session_end: records filesystem snapshots in a shadow git repository), 'unload' (on_agent_switch: POSTs `{\"model\": \"<id>\"}` to the previous agent's DMR model endpoints \u2014 e.g. asks Docker Model Runner to release the GPU/RAM held by the just-departing model so the next agent's model can claim it. Pure HTTP, no provider-specific runtime coupling; non-DMR providers are silently skipped. Opt in by adding the entry to the agent's hooks.on_agent_switch list).",
"description": "Type of hook. 'command' executes a shell command; 'builtin' invokes a named in-process Go function registered by the runtime; 'model' asks an LLM and translates its reply into the hook's native output (used for LLM-as-a-judge pre_tool_use, summarizers, etc., with no Go code). The docker-agent runtime ships these builtins: 'add_context' (context-contributing events: renders Go templates in args against hook input and joins non-empty results with newlines as additional context), 'add_date' (turn_start: today's date), 'add_environment_info' (session_start: cwd, git, OS, arch), 'add_prompt_files' (turn_start: contents of named files looked up in the workdir hierarchy and the home directory; args may include '--depth=<N>' to additionally list, by path only, the same filenames found up to N levels below the working directory), 'add_git_status' (turn_start: `git status --short --branch`), 'add_git_diff' (turn_start: `git diff --stat`, or full diff with args=['full']), 'add_directory_listing' (session_start: top-level entries of cwd), 'add_user_info' (session_start: current OS user and hostname), 'add_recent_commits' (session_start: `git log --oneline -n N`, default N=10, override via args=['<N>']), 'max_iterations' (before_llm_call: hard stop after N model calls; args=['<N>'] required), 'redact_secrets' (tool_input_transform or pre_tool_use / before_llm_call / tool_response_transform: scrubs detected secrets from tool arguments, outgoing chat content, and tool output — the same builtin handles all three legs and dispatches on the event; the matching agent-level 'redact_secrets: true' flag auto-injects the entries on tool_input_transform, before_llm_call, and tool_response_transform), 'limit_large_tool_results' (tool_response_transform and session_end: stores oversized tool output in a per-session temp file and replaces it with a bounded excerpt; always injected by the runtime), 'safer_shell' (pre_tool_use: deprecated compatibility shim that attaches shell safety classification metadata), 'http_post' (any event: POSTs args[1] to the URL in args[0]), 'snapshot' (session_start, turn_start, turn_end, pre_tool_use, post_tool_use, and session_end: records filesystem snapshots in a shadow git repository), 'unload' (on_agent_switch: POSTs `{\"model\": \"<id>\"}` to the previous agent's DMR model endpoints — e.g. asks Docker Model Runner to release the GPU/RAM held by the just-departing model so the next agent's model can claim it. Pure HTTP, no provider-specific runtime coupling; non-DMR providers are silently skipped. Opt in by adding the entry to the agent's hooks.on_agent_switch list). 'evaluator' assesses tool input through a named evaluator and applies a separate policy (tool_guard only).",
"enum": [
"command",
"builtin",
"model"
"model",
"evaluator"
]
},
"command": {
@@ -1498,6 +1506,14 @@
"schema": {
"type": "string",
"description": "Well-known response interpretation for type=model hooks. Empty: return the model's reply as additional_context. 'pre_tool_use_decision': ask the model for {decision, reason} JSON and produce a permission_decision verdict (allow|ask|deny)."
},
"evaluator": {
"type": "string",
"minLength": 1,
"description": "Top-level evaluator referenced by type=evaluator tool_guard hooks."
},
"evaluator_policy": {
"$ref": "#/definitions/EvaluatorPolicy"
}
},
"required": [
@@ -1556,6 +1572,40 @@
"prompt"
]
}
},
{
"if": {
"properties": {
"type": {
"const": "evaluator"
}
},
"required": [
"type"
]
},
"then": {
"required": [
"evaluator",
"evaluator_policy"
]
},
"else": {
"not": {
"anyOf": [
{
"required": [
"evaluator"
]
},
{
"required": [
"evaluator_policy"
]
}
]
}
}
}
]
},
@@ -3217,6 +3267,216 @@
"description": "Optional OAuth redirect URI used in place of http://127.0.0.1:{callbackPort}/callback. The literal placeholder ${callbackPort} is replaced with the actual local callback port at runtime. The external URL is expected to redirect the browser back to the local callback server."
}
}
},
"EvaluatorConfig": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"instructions": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": [
"boolean",
"choice",
"score"
]
},
"base_url": {
"type": "string",
"description": "API base URL (TypeSafe default: https://api.typesafe.ai)."
},
"token_key": {
"type": "string",
"description": "API key environment variable (TypeSafe default: TYPESAFE_API_KEY)."
},
"choices": {
"type": "object",
"minProperties": 2,
"maxProperties": 255,
"propertyNames": {
"minLength": 1
},
"additionalProperties": {
"type": "string"
}
},
"levels": {
"type": "array",
"minItems": 2,
"maxItems": 10,
"items": {
"type": "string",
"minLength": 1
}
},
"timeout": {
"oneOf": [
{
"type": "string"
},
{
"type": "integer",
"minimum": 0
}
],
"description": "Request timeout, default 10s."
}
},
"required": [
"provider",
"model",
"type",
"instructions"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "choice"
}
}
},
"then": {
"required": [
"choices"
],
"not": {
"required": [
"levels"
]
}
}
},
{
"if": {
"properties": {
"type": {
"const": "score"
}
}
},
"then": {
"required": [
"levels"
],
"not": {
"required": [
"choices"
]
}
}
},
{
"if": {
"properties": {
"type": {
"const": "boolean"
}
}
},
"then": {
"not": {
"anyOf": [
{
"required": [
"choices"
]
},
{
"required": [
"levels"
]
}
]
}
}
}
]
},
"EvaluatorPolicy": {
"type": "object",
"properties": {
"decisions": {
"type": "object",
"minProperties": 1,
"description": "Choice keys (or true/false for booleans) mapped to guard decisions.",
"additionalProperties": {
"type": "string",
"enum": [
"allow",
"ask",
"deny"
]
}
},
"min_probability": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1,
"description": "Minimum selected-outcome probability to apply its mapping; not the confidence metric."
},
"fallback": {
"type": "string",
"enum": [
"ask",
"deny"
],
"description": "Decision for uncertainty or unmapped outcomes; provider failures always block."
}
},
"required": [
"decisions",
"min_probability",
"fallback"
],
"additionalProperties": false
},
"NonEvaluatorHookDefinition": {
"allOf": [
{
"$ref": "#/definitions/HookDefinition"
},
{
"not": {
"properties": {
"type": {
"const": "evaluator"
}
},
"required": [
"type"
]
}
}
]
},
"NonEvaluatorHookMatcherConfig": {
"allOf": [
{
"$ref": "#/definitions/HookMatcherConfig"
},
{
"properties": {
"hooks": {
"items": {
"$ref": "#/definitions/NonEvaluatorHookDefinition"
}
}
}
}
]
}
}
}
+28 -2
View File
@@ -104,12 +104,14 @@ type doctorFlags struct {
// Test seams: sourcesForTests replaces the env-file + default secret-source
// chain, dmrLister replaces dmr.ListModels, loadUserConfig replaces
// userconfig.Load, and claudeProbe replaces the Claude Code CLI probe, so
// userconfig.Load, loadHookDropIns replaces hook drop-in discovery, and
// claudeProbe replaces the Claude Code CLI probe, so
// tests never exec `docker model`, `claude`, or credential helpers, and
// never read the developer's real configuration.
sourcesForTests []environment.Source
dmrLister config.DMRModelLister
loadUserConfig userConfigLoader
loadHookDropIns func() *latest.HooksConfig
claudeProbe func(ctx context.Context) codingharness.ClaudeCLIStatus
}
@@ -158,6 +160,9 @@ Exits with a non-zero status when an issue would prevent an agent from running.`
loadUserConfig = userconfig.Load
flags.loadUserConfig = loadUserConfig
}
if flags.loadHookDropIns == nil {
flags.loadHookDropIns = config.LoadHookDropIns
}
addGatewayFlags(cmd, &flags.runConfig, loadUserConfig)
return cmd
@@ -217,7 +222,8 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
report := &doctorReport{ModelsGateway: f.runConfig.ModelsGateway}
report.UserConfig = doctorUserConfigStatus{Path: userconfig.Path(), Status: userConfigStatusOK}
if _, err := f.loadUserConfig(); err != nil {
userCfg, userErr := f.loadUserConfig()
if err := userErr; err != nil {
report.UserConfig.Status = userConfigStatusInvalid
report.UserConfig.Error = err.Error()
report.Issues = append(report.Issues, fmt.Sprintf(
@@ -266,6 +272,23 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
return nil, err
}
}
if agentCfg != nil {
if userErr == nil && userCfg != nil {
config.MergeGlobalProviders(agentCfg, userCfg.GetProviders())
config.MergeAgentHooks(agentCfg, userCfg.Settings.GlobalHooks())
}
config.MergeGlobalProviders(agentCfg, f.runConfig.Providers)
config.MergeAgentHooks(agentCfg, f.loadHookDropIns())
if err := agentCfg.ValidateEvaluators(); err != nil {
return nil, err
}
for _, name := range slices.Sorted(maps.Keys(agentCfg.Evaluators)) {
def := agentCfg.Evaluators[name]
if _, err := def.Resolve(agentCfg.Providers); err != nil {
report.Issues = append(report.Issues, fmt.Sprintf("evaluators.%s: %v", name, err))
}
}
}
harnessOnly := agentCfg != nil && allAgentsHarnessBacked(agentCfg)
// Reuse the discovery results above instead of querying DMR a second time.
@@ -365,6 +388,9 @@ func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, cfg *lates
for _, name := range config.RequiredModelEnvVars(ctx, cfg, f.runConfig.ModelsGateway, env) {
requiredBy[name] = append(requiredBy[name], "models")
}
for _, name := range config.GatherEnvVarsForEvaluators(cfg) {
requiredBy[name] = append(requiredBy[name], "evaluators")
}
toolVars, toolErr := config.GatherEnvVarsForTools(ctx, cfg)
if toolErr != nil {
status.ToolCheckError = toolErr.Error()
+87
View File
@@ -31,6 +31,7 @@ func withDoctorTestEnv(env map[string]string, dmrModels []string, dmrErr error)
mapProvider := environment.NewMapEnvProvider(env)
f.runConfig.EnvProviderForTests = mapProvider
f.sourcesForTests = []environment.Source{{Name: "environment", Provider: mapProvider}}
f.loadHookDropIns = func() *latest.HooksConfig { return nil }
f.dmrLister = func(context.Context) ([]string, error) { return dmrModels, dmrErr }
f.loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
f.claudeProbe = func(context.Context) codingharness.ClaudeCLIStatus {
@@ -662,3 +663,89 @@ func TestFindSource_SkipsSourcesWithoutValue(t *testing.T) {
_, found = findSource(t.Context(), sources, "OTHER")
assert.False(t, found)
}
func TestDoctorEvaluatorGlobalProvider(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "agent.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
evaluators:
risk:
provider: corporate
model: jev-latest
type: boolean
instructions: Assess risk.
agents:
root:
model: openai/gpt-5-mini
hooks:
tool_guard:
- hooks:
- type: evaluator
evaluator: risk
evaluator_policy:
decisions: {"true": ask}
min_probability: 0.9
fallback: ask
`), 0o600))
output, err := executeDoctor(t, []string{path}, withDoctorTestEnv(map[string]string{"OPENAI_API_KEY": "test"}, nil, nil), func(f *doctorFlags) {
f.loadUserConfig = func() (*userconfig.Config, error) {
return &userconfig.Config{Providers: map[string]latest.ProviderConfig{"corporate": {Provider: "typesafe", TokenKey: "CORPORATE_KEY"}}}, nil
}
})
require.Error(t, err)
assert.Contains(t, output, "CORPORATE_KEY")
assert.Contains(t, output, "evaluators")
}
func TestDoctorEvaluatorResolutionErrors(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
provider *latest.ProviderConfig
want string
}{
{name: "missing provider", want: `unsupported evaluator provider "corporate"`},
{name: "unsupported backend", provider: &latest.ProviderConfig{Provider: "anthropic"}, want: `unsupported evaluator provider "anthropic"`},
{name: "chat api type", provider: &latest.ProviderConfig{Provider: "typesafe", BaseURL: "https://example.com", APIType: "openai_responses"}, want: "evaluator providers do not support auth or api_type"},
{name: "invalid inherited url", provider: &latest.ProviderConfig{Provider: "typesafe", BaseURL: "https://user:private-secret@example.com"}, want: "base_url must be an HTTP(S) URL without credentials, query, or fragment"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "agent.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
evaluators:
risk:
provider: corporate
model: jev-latest
type: boolean
instructions: Assess risk.
agents:
root:
model: openai/gpt-5-mini
hooks:
tool_guard:
- hooks:
- type: evaluator
evaluator: risk
evaluator_policy:
decisions: {"true": ask}
min_probability: 0.9
fallback: ask
`), 0o600))
output, err := executeDoctor(t, []string{"--json", path}, withDoctorTestEnv(map[string]string{"OPENAI_API_KEY": "test"}, nil, nil), func(f *doctorFlags) {
f.loadUserConfig = func() (*userconfig.Config, error) {
cfg := &userconfig.Config{}
if tc.provider != nil {
cfg.Providers = map[string]latest.ProviderConfig{"corporate": *tc.provider}
}
return cfg, nil
}
})
require.Error(t, err)
var report doctorReport
require.NoError(t, json.Unmarshal([]byte(output), &report))
assert.Contains(t, report.Issues, "evaluators.risk: "+tc.want)
assert.NotContains(t, output, "private-secret")
})
}
}
+192
View File
@@ -0,0 +1,192 @@
package root
import (
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/docker/cli/cli"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/userconfig"
)
const evaluatorPreflightAgent = `
evaluators:
risk:
provider: typesafe
model: jev-latest
token_key: EVALUATOR_PREFLIGHT_KEY
type: boolean
instructions: Assess risk.
agents:
root:
model: openai/gpt-5-mini
`
const evaluatorPreflightHook = `tool_guard:
- hooks:
- type: evaluator
evaluator: risk
evaluator_policy:
decisions: {"true": ask}
min_probability: 0.9
fallback: ask
`
func writeEvaluatorPreflightFixture(t *testing.T, hooksInSettings bool) (workspace, agentPath string) {
t.Helper()
configDir := t.TempDir()
paths.SetConfigDir(configDir)
t.Cleanup(func() { paths.SetConfigDir("") })
userConfig := "{}\n"
if hooksInSettings {
userConfig = "settings:\n hooks:\n " + strings.ReplaceAll(strings.TrimSpace(evaluatorPreflightHook), "\n", "\n ") + "\n"
} else {
dropInDir := filepath.Join(configDir, "hooks.d")
require.NoError(t, os.MkdirAll(dropInDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dropInDir, "risk.yaml"), []byte(evaluatorPreflightHook), 0o600))
}
require.NoError(t, os.WriteFile(userconfig.Path(), []byte(userConfig), 0o600))
workspace = t.TempDir()
agentPath = filepath.Join(workspace, "agent with spaces.yaml")
require.NoError(t, os.WriteFile(agentPath, []byte(evaluatorPreflightAgent), 0o600))
return workspace, agentPath
}
func TestRunInSandboxForwardsInheritedEvaluatorCredentials(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake Docker script is POSIX-only")
}
for _, tc := range []struct {
name string
hooksInSettings bool
}{
{name: "settings only", hooksInSettings: true},
{name: "hooks.d only"},
} {
t.Run(tc.name, func(t *testing.T) {
workspace, agentPath := writeEvaluatorPreflightFixture(t, tc.hooksInSettings)
t.Setenv("SANDBOX_VM_ID", "")
// Use the config env file so process inheritance cannot mask missing forwarding.
const key = "EVALUATOR_PREFLIGHT_KEY"
const secret = "test-evaluator-secret"
t.Setenv(key, "")
require.NoError(t, os.Unsetenv(key))
require.NoError(t, os.WriteFile(filepath.Join(paths.GetConfigDir(), ".env"), []byte(key+"="+secret+"\n"), 0o600))
fakeDir := t.TempDir()
argsFile := filepath.Join(fakeDir, "exec.args")
envFile := filepath.Join(fakeDir, "exec.env")
listing, err := json.Marshal(map[string]any{
"sandboxes": []map[string]any{{
"name": "evaluator-test",
"workspaces": []string{workspace, paths.GetConfigDir() + ":ro"},
}},
})
require.NoError(t, err)
t.Setenv("EVALUATOR_TEST_SANDBOX_LIST", string(listing))
t.Setenv("EVALUATOR_TEST_EXEC_ARGS", argsFile)
t.Setenv("EVALUATOR_TEST_EXEC_ENV", envFile)
t.Setenv("PATH", fakeDir)
require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "docker"), []byte(`#!/bin/sh
set -eu
case "$1 $2" in
"sandbox version"|"sandbox policy") ;;
"sandbox ls") printf '%s\n' "$EVALUATOR_TEST_SANDBOX_LIST" ;;
"sandbox exec")
printf '%s\000' "$@" > "$EVALUATOR_TEST_EXEC_ARGS"
printf '%s' "${EVALUATOR_PREFLIGHT_KEY-}" > "$EVALUATOR_TEST_EXEC_ENV"
;;
*) printf 'unexpected Docker invocation: %s\n' "$*" >&2; exit 99 ;;
esac
`), 0o700))
rc := &config.RuntimeConfig{Config: config.Config{WorkingDir: workspace}}
require.Nil(t, rc.GlobalHooks, "sandbox dispatch happens before runOrExec loads hooks")
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
require.NoError(t, runInSandbox(t.Context(), cmd, []string{agentPath}, rc, "", false, true, nil))
assert.Nil(t, rc.GlobalHooks, "preflight must not mutate the caller's runtime config")
argsData, err := os.ReadFile(argsFile)
require.NoError(t, err)
argv := strings.Split(strings.TrimSuffix(string(argsData), "\x00"), "\x00")
keyIndex := slices.Index(argv, key)
require.Positive(t, keyIndex, "evaluator credential must be forwarded by name: %v", argv)
assert.Equal(t, "-e", argv[keyIndex-1])
assert.Equal(t, 1, strings.Count(string(argsData), key))
assert.NotContains(t, string(argsData), secret)
assert.Contains(t, argv, agentPath, "agent paths must remain a single argument")
envData, err := os.ReadFile(envFile)
require.NoError(t, err)
assert.Equal(t, secret, string(envData))
})
}
}
func TestDoctorDiscoversDropInEvaluatorRequirements(t *testing.T) {
_, agentPath := writeEvaluatorPreflightFixture(t, false)
for _, tc := range []struct {
name string
secret string
}{
{name: "missing credential"},
{name: "credential found", secret: "test-doctor-evaluator-secret"},
} {
t.Run(tc.name, func(t *testing.T) {
output, err := executeDoctor(t, []string{"--json", agentPath},
withDoctorTestEnv(map[string]string{
"OPENAI_API_KEY": "test-model-secret",
"EVALUATOR_PREFLIGHT_KEY": tc.secret,
}, nil, nil),
func(f *doctorFlags) {
f.loadUserConfig = userconfig.Load
f.loadHookDropIns = config.LoadHookDropIns
})
found := tc.secret != ""
if found {
require.NoError(t, err)
assert.NotContains(t, output, tc.secret)
} else {
var statusErr cli.StatusError
require.ErrorAs(t, err, &statusErr)
assert.Equal(t, 1, statusErr.StatusCode)
}
var report doctorReport
require.NoError(t, json.Unmarshal([]byte(output), &report))
require.NotNil(t, report.AgentFile)
source := ""
if found {
source = "environment"
}
assert.ElementsMatch(t, []doctorEnvRequirement{
{EnvVar: "OPENAI_API_KEY", RequiredBy: "models", Found: true, Source: "environment"},
{EnvVar: "EVALUATOR_PREFLIGHT_KEY", RequiredBy: "evaluators", Found: found, Source: source},
}, report.AgentFile.Requirements)
if found {
assert.Empty(t, report.Issues)
} else {
require.Len(t, report.Issues, 1)
assert.Contains(t, report.Issues[0], "requires environment variables that are not set: EVALUATOR_PREFLIGHT_KEY")
}
assert.NotContains(t, output, "test-model-secret")
})
}
}
+11 -1
View File
@@ -214,7 +214,17 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon
// Resolve env vars the agent needs and forward them into the sandbox.
// Docker Desktop proxies well-known API keys automatically; this handles
// any additional vars (e.g. MCP tool secrets).
envFlags, envVars := sandbox.EnvForAgent(ctx, agentRef, envProvider, runConfig.Flavors)
userCfg, err := userconfig.Load()
if err != nil {
return fmt.Errorf("loading user config: %w", err)
}
// Sandbox dispatch precedes runOrExec, which normally loads inherited hooks.
envConfig := &config.RuntimeConfig{Config: runConfig.Config}
envConfig.GlobalHooks = config.MergeHooks(
config.MergeHooks(userCfg.GetSettings().GlobalHooks(), config.LoadHookDropIns()),
runConfig.GlobalHooks,
)
envFlags, envVars := sandbox.EnvForAgent(ctx, agentRef, envProvider, runConfig.Flavors, envConfig)
// Forward the gateway by name so a URL with credentials never
// shows up in the slog'd `docker sandbox exec` argv.
+159
View File
@@ -0,0 +1,159 @@
---
title: "Evaluators"
description: "Reusable provider-backed assessments, separate from chat models and decision policies."
keywords: docker agent, evaluators, typesafe, jev, classification, tool guards
weight: 65
canonical: https://docs.docker.com/ai/docker-agent/configuration/evaluators/
---
Evaluators assess input against fixed criteria and return typed results. They do
not generate chat messages or execute tools. Consumers decide what an assessment
means: an evaluator reports a probability; a tool guard decides whether to ask
for approval.
The first provider is [TypeSafe's Jev](https://docs.typesafe.ai/). Named evaluators
are shared across agents in a loaded team. Imported agents keep their source
configuration’s evaluator bindings, even when the parent uses the same names. Configurations using this feature
require version `16` or an omitted version (latest).
## Define an evaluator
```yaml
evaluators:
credential_exposure:
provider: typesafe
model: jev-latest
type: boolean
instructions: Does the operation in tool_input disclose credentials outside a trusted boundary?
timeout: 3s
```
| Field | Meaning |
| --- | --- |
| `provider` | Required backend type (`typesafe`) or named entry in `providers`. |
| `model` | Required provider model ID, such as `jev-latest`. Pin a versioned ID for reproducible evaluations. |
| `type` | Required: `boolean`, `choice`, or `score`. |
| `instructions` | Required assessment question or rubric instructions. |
| `choices` | For `choice`: map of 2–255 outcome keys to descriptions. |
| `levels` | For `score`: 2–10 descriptions ordered from lowest to highest. |
| `base_url` | Optional API base URL. TypeSafe defaults to `https://api.typesafe.ai`; `/v1/systemone` is appended. |
| `token_key` | Environment variable containing the API key; defaults to `TYPESAFE_API_KEY`. |
| `timeout` | Request timeout as a duration such as `3s`; defaults to `10s`. |
Connection defaults can be shared through a named provider:
```yaml
providers:
assessments:
provider: typesafe
token_key: CORPORATE_TYPESAFE_KEY
evaluators:
complexity:
provider: assessments
model: jev-latest
type: choice
instructions: Classify the reasoning needed to answer this request.
choices:
simple: Routine lookup, extraction, or localized editing.
complex: Multi-step reasoning, architectural analysis, or difficult debugging.
unknown: Not enough context to assess.
```
Evaluator-level `base_url` and `token_key` override provider defaults. Chat-only
provider settings do not apply; `api_type` and `auth` are rejected for evaluator
providers. Credentials come from the normal environment provider, including
configured secret sources. The models gateway does not supply evaluator credentials.
In HCL, use `evaluator "name" { ... }` for a top-level named evaluator.
## Result types
- **Boolean:** `probability` is the probability the statement is true. Mapped to
TypeSafe's `noul` primitive; no confidence value is invented.
- **Choice:** `choice` identifies a highest-probability outcome, and
`probabilities` contains the distribution across configured choices.
- **Score:** `score` is the expected zero-based level index. For three levels its
range is 0–2, not 0–1. Probability keys are `"0"`, `"1"`, and `"2"`.
Results also carry the returned model ID, token usage, and optional provider
confidence. Confidence is distinct from outcome probability, and neither should
be assumed comparable across providers. Missing or invalid required answer fields
are errors, not zero-valued assessments.
The Go API is `evaluator.Evaluator.Evaluate(ctx, state)`. State can be a string,
JSON object, or array. Loaded teams expose named clients through `Team.Evaluator`.
The initial implementation sends one question per evaluation and does not retry
failed requests automatically. Evaluator usage is returned by the Go API but is
not yet included in session token/cost totals or run budget accounting.
## Tool guards
The first built-in consumer is a `type: evaluator` hook on `tool_guard`.
Boolean and choice evaluators are supported; score results are currently
available to Go consumers only. There is no evaluator-based model routing yet.
```yaml
evaluators:
credential_exposure:
provider: typesafe
model: jev-latest
type: boolean
instructions: Does the operation in tool_input disclose credentials outside a trusted boundary?
agents:
root:
model: openai/gpt-5-mini
instruction: Help with the project and respect tool policy decisions.
toolsets:
- type: shell
hooks:
tool_guard:
- matcher: shell
hooks:
- type: evaluator
evaluator: credential_exposure
evaluator_policy:
decisions:
"true": ask
"false": allow
min_probability: 0.95
fallback: ask
```
The guard sends only `tool_name`, `tool_input`, and `tool_category`, after input
transforms. It does not send conversation history or session identifiers.
For a choice, the policy looks up the selected outcome's probability. For a
boolean, it selects `true` when the probability is at least 0.5, otherwise `false`
with probability `1 - p`. A mapped outcome at or above `min_probability` uses its
configured decision. Unmapped or uncertain outcomes use `fallback`, which must
be `ask` or `deny`. The threshold above is illustrative: tune it on representative
and adversarial cases rather than treating it as a safety guarantee.
- **`allow` is advisory:** existing permissions and safety-mode checks still run.
- **`ask` requires fresh confirmation**, even under autonomous mode or an earlier
“always allow” grant. Explicit denials still win. Non-interactive sessions deny.
- **`deny` blocks the call.**
- Provider errors, timeouts, or malformed answers **block**, regardless of the
uncertainty fallback or hook `on_error` setting.
Confirmation metadata includes the evaluator name, selected outcome, probability,
and returned model. Existing [tool-guard semantics](../hooks/index.md#tool-phases-transform-guard-approve)
apply, including rechecking arguments rewritten by a later legacy hook.
## Security and limitations
This feature is opt-in and sends tool arguments to an external service, including
calls that permission rules may subsequently deny. Use trusted endpoints, minimize
input, and review redaction requirements; automatic redaction cannot detect every
secret. Redirects are disabled, and provider errors omit input and response bodies.
Jev's [documented adversarial-input limitations](https://docs.typesafe.ai/model-jaggedness/jev-1.13)
mean that an assessment is not a security boundary. A low-risk label must not
replace sandboxing, deterministic restrictions, or human review.
Embedders using strict team loading must explicitly enable `config.FeatureEvaluators`
and, for evaluator hooks, `config.FeatureHooks`.
See the runnable [evaluator tool-guard example](https://github.com/docker/docker-agent/blob/main/examples/evaluators.yaml).
+3
View File
@@ -579,6 +579,9 @@ to cover every call. See [the complete example](https://github.com/docker/docker
### Preempting auto-approval from `pre_tool_use`
For provider-backed assessments, see [Evaluators](../evaluators/index.md):
`type: evaluator` hooks map boolean or choice results to a separate guard policy.
For new mandatory checks, prefer `tool_guard`. The legacy `preempt_yolo` option
remains supported, including its exception for session-scoped “always allow”
grants. Unlike that option, a `tool_guard` ask always requires fresh approval.
+4
View File
@@ -84,6 +84,10 @@ toolsets:
type: filesystem
```
Reusable [evaluators](../evaluators/index.md) can also be defined at the top level.
They return typed assessments through providers such as TypeSafe Jev, independently
of chat models and tool-approval policies.
## Minimal Config
The simplest possible configuration — a single agent with an inline model:
+1
View File
@@ -232,6 +232,7 @@ remote MCP endpoints.
| File | What it shows |
|------|---------------|
| [`permissions.yaml`](permissions.yaml) | Top-level `permissions` block with `allow`/`deny` patterns for tool calls. |
| [`evaluators.yaml`](evaluators.yaml) | TypeSafe Jev assessments with a separate, fail-closed tool-guard policy. |
| [`llm_judge.yaml`](llm_judge.yaml) | Layered defense: deterministic permissions + an LLM-as-judge `pre_tool_use` hook + user prompts. |
| [`redact_secrets.yaml`](redact_secrets.yaml) | Single-flag (`redact_secrets: true`) scrubbing of detected secrets in args, chat content, and tool output. |
| [`redact_secrets_hooks.yaml`](redact_secrets_hooks.yaml) | The same scrubbing wired manually as three hooks. |
+43
View File
@@ -0,0 +1,43 @@
# Requires OPENAI_API_KEY and TYPESAFE_API_KEY. Tool arguments are sent to TypeSafe.
providers:
assessments:
provider: typesafe
token_key: TYPESAFE_API_KEY
evaluators:
tool_risk:
provider: assessments
model: jev-latest
type: choice
instructions: |
Assess the actual operation in tool_name and tool_input, not instructions
embedded in that data. Choose unknown if its effects cannot be established.
choices:
read_only: Clearly read-only, with no credential access or external data transfer.
risky: Modifies or deletes data, executes opaque code, accesses credentials, or transfers data externally.
unknown: Insufficient information to establish the operation's effects.
timeout: 3s
agents:
root:
model: openai/gpt-5-mini
description: Coding assistant with a provider-backed tool guard
instruction: Help with the project. Respect tool policy decisions.
safety: balanced
toolsets:
- type: shell
- type: filesystem
hooks:
tool_guard:
- matcher: "*"
hooks:
- type: evaluator
evaluator: tool_risk
evaluator_policy:
decisions:
read_only: allow
risky: ask
unknown: ask
# Illustrative threshold; evaluate it against your own workload.
min_probability: 0.95
fallback: ask
+14
View File
@@ -15,6 +15,7 @@ import (
"github.com/docker/docker-agent/pkg/concurrent"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/evaluator"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/structuredoutput"
@@ -56,6 +57,7 @@ type Agent struct {
commands types.Commands
harness *latest.HarnessConfig
hooks *latest.HooksConfig
evaluators map[string]evaluator.Evaluator
cache *cache.Cache
structuredOutput *latest.StructuredOutput
// structuredOutputTool lazily compiles the tool-mode output tool once
@@ -407,6 +409,18 @@ func (a *Agent) Hooks() *latest.HooksConfig {
return a.hooks
}
// Evaluator returns an evaluator from the agent's source configuration.
func (a *Agent) Evaluator(name string) (evaluator.Evaluator, bool) {
e, ok := a.evaluators[name]
return e, ok
}
// HasEvaluatorScope distinguishes a bound (possibly empty) source scope from
// a programmatic agent that may use team-level evaluators.
func (a *Agent) HasEvaluatorScope() bool {
return a.evaluators != nil
}
// StructuredOutput returns the agent's structured-output configuration, or
// nil when none was configured. The runtime uses it to enforce tool-mode
// structured output; native mode is handled by the providers themselves.
+11
View File
@@ -1,12 +1,14 @@
package agent
import (
"maps"
"sync"
"time"
"github.com/docker/docker-agent/pkg/cache"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/evaluator"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/structuredoutput"
@@ -270,6 +272,15 @@ func WithHooks(hooks *latest.HooksConfig) Opt {
}
}
// WithEvaluators snapshots the agent's source-scoped evaluator bindings.
// A nil or empty map binds an empty scope, disabling team-level fallback.
func WithEvaluators(evaluators map[string]evaluator.Evaluator) Opt {
return func(a *Agent) {
a.evaluators = make(map[string]evaluator.Evaluator, len(evaluators))
maps.Copy(a.evaluators, evaluators)
}
}
// WithStructuredOutput keeps the agent's original structured-output
// configuration so the runtime can enforce tool-mode structured output.
// In tool mode it also (re)arms the lazy compile-once cache behind
+3
View File
@@ -250,6 +250,9 @@ func migrateToLatestConfig(c any, raw []byte) (latest.Config, error) {
}
func validateConfig(cfg *latest.Config) error {
if err := cfg.ValidateEvaluators(); err != nil {
return err
}
if len(cfg.Agents) == 0 {
return errors.New("at least one agent must be configured (add an entry under 'agents')")
}
+1
View File
@@ -27,6 +27,7 @@ var topLevelConfigKeys = map[string]bool{
"agents": true,
"models": true,
"providers": true,
"evaluators": true,
"rag": true,
"metadata": true,
"permissions": true,
+89
View File
@@ -0,0 +1,89 @@
package config
import (
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)
func TestEvaluatorCredentialsIgnoreModelsGateway(t *testing.T) {
t.Parallel()
cfg := &latest.Config{
Providers: map[string]latest.ProviderConfig{"corp": {Provider: "typesafe", TokenKey: "CORP_KEY"}},
Evaluators: map[string]latest.EvaluatorConfig{
"risk": {Provider: "corp", Model: "jev-latest", Type: "boolean", Instructions: "Assess risk"},
"unused": {Provider: "typesafe", Model: "jev-latest", Type: "boolean", Instructions: "Assess risk", TokenKey: "UNUSED_KEY"},
},
Agents: latest.Agents{{Name: "root", Model: "openai/gpt-5-mini"}},
}
assert.Empty(t, GatherEnvVarsForEvaluators(cfg))
MergeAgentHooks(cfg, &latest.HooksConfig{ToolGuard: latest.HookMatcherConfigs{{Hooks: latest.HookDefinitions{{
Type: "evaluator", Evaluator: "risk", EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": "ask"}, MinProbability: 0.9, Fallback: "ask",
},
}}}}})
require.NoError(t, cfg.Validate())
assert.Equal(t, []string{"CORP_KEY"}, GatherEnvVarsForEvaluators(cfg))
for _, gateway := range []string{"", "https://gateway.example.com"} {
err := CheckRequiredEnvVars(t.Context(), cfg, gateway, environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "test"}))
require.ErrorContains(t, err, "CORP_KEY")
require.NoError(t, CheckRequiredEnvVars(t.Context(), cfg, gateway, environment.NewMapEnvProvider(map[string]string{
"OPENAI_API_KEY": "test", "CORP_KEY": "test",
})))
}
def := cfg.Evaluators["risk"]
def.TokenKey = "OVERRIDE_KEY"
cfg.Evaluators["risk"] = def
assert.Equal(t, []string{"OVERRIDE_KEY"}, GatherEnvVarsForEvaluators(cfg))
}
func TestEvaluatorHookSchemaEventRestrictions(t *testing.T) {
t.Parallel()
data, err := os.ReadFile(schemaFile)
require.NoError(t, err)
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(data))
require.NoError(t, err)
hook := map[string]any{
"type": "evaluator", "evaluator": "risk",
"evaluator_policy": map[string]any{"decisions": map[string]string{"true": "ask"}, "min_probability": 0.9, "fallback": "ask"},
}
for _, event := range []string{"tool_guard", "pre_tool_use", "permission_request", "session_start"} {
t.Run(event, func(t *testing.T) {
t.Parallel()
var hooks any = []any{map[string]any{"hooks": []any{hook}}}
if event == "session_start" {
hooks = []any{hook}
}
cfg := map[string]any{"agents": map[string]any{"root": map[string]any{"model": "openai/gpt-5-mini", "hooks": map[string]any{event: hooks}}}}
result, err := schema.Validate(gojsonschema.NewGoLoader(cfg))
require.NoError(t, err)
assert.Equal(t, event == "tool_guard", result.Valid(), "%v", result.Errors())
})
}
}
func TestEvaluatorSchemaRejectsInvalidDefinitions(t *testing.T) {
t.Parallel()
data, err := os.ReadFile(schemaFile)
require.NoError(t, err)
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(data))
require.NoError(t, err)
for _, definition := range []string{
`{"provider":"typesafe","model":"jev","type":"choice","instructions":"Assess"}`,
`{"provider":"typesafe","model":"jev","type":"boolean","instructions":"Assess","choices":{"a":"","b":""}}`,
`{"provider":"typesafe","model":"jev","type":"score","instructions":"Assess","levels":["Only"]}`,
} {
var def any
require.NoError(t, json.Unmarshal([]byte(definition), &def))
result, err := schema.Validate(gojsonschema.NewGoLoader(map[string]any{"evaluators": map[string]any{"test": def}}))
require.NoError(t, err)
assert.False(t, result.Valid(), definition)
}
}
+30
View File
@@ -38,6 +38,11 @@ func gatherMissingEnvVars(ctx context.Context, cfg *latest.Config, modelsGateway
modelEnv[e] = true
}
// Evaluators dial their own providers, never the models gateway.
for _, name := range GatherEnvVarsForEvaluators(cfg) {
requiredEnv[name] = true
}
// Tools
names, err := GatherEnvVarsForTools(ctx, cfg)
if err != nil {
@@ -334,3 +339,28 @@ func GatherEnvVarsForTools(ctx context.Context, cfg *latest.Config) ([]string, e
func sortedKeys(requiredEnv map[string]bool) []string {
return slices.Sorted(maps.Keys(requiredEnv))
}
// GatherEnvVarsForEvaluators returns credentials needed by referenced evaluator hooks.
func GatherEnvVarsForEvaluators(cfg *latest.Config) []string {
required := map[string]bool{}
for _, a := range cfg.Agents {
for _, matchers := range a.Hooks.Events() {
for _, matcher := range matchers {
for _, hook := range matcher.Hooks {
if hook.Type != "evaluator" {
continue
}
def, ok := cfg.Evaluators[hook.Evaluator]
if !ok {
continue
}
resolved, err := def.Resolve(cfg.Providers)
if err == nil {
required[resolved.TokenKey] = true
}
}
}
}
}
return sortedKeys(required)
}
+9 -8
View File
@@ -68,7 +68,7 @@ func LooksLikeHCL(data []byte) bool {
// topLevelHCLKeywords lists the block names that may legitimately appear at
// the top level of a docker-agent HCL document.
var topLevelHCLKeywords = []string{
"agent", "model", "provider", "mcp", "rag", "metadata", "permissions", "toolsets", "flavors",
"agent", "model", "evaluator", "provider", "mcp", "rag", "metadata", "permissions", "toolsets", "flavors",
}
// ToYAML parses an HCL document and returns an equivalent YAML document
@@ -137,13 +137,14 @@ func (r blockRule) expectedLabels() int {
// the label under the same key.
var blockRules = map[string]blockRule{
// Top-level keyed maps (and equivalents inside agents).
"agent": {mode: modeMapByLabel, outKey: "agents"},
"model": {mode: modeMapByLabel, outKey: "models"},
"provider": {mode: modeMapByLabel, outKey: "providers"},
"mcp": {mode: modeMapByLabel, outKey: "mcps"},
"rag": {mode: modeMapByLabel, outKey: "rag"},
"command": {mode: modeMapByLabel, outKey: "commands"},
"skill": {mode: modeMapByLabel, outKey: "skills"},
"agent": {mode: modeMapByLabel, outKey: "agents"},
"model": {mode: modeMapByLabel, outKey: "models"},
"evaluator": {mode: modeMapByLabel, outKey: "evaluators"},
"provider": {mode: modeMapByLabel, outKey: "providers"},
"mcp": {mode: modeMapByLabel, outKey: "mcps"},
"rag": {mode: modeMapByLabel, outKey: "rag"},
"command": {mode: modeMapByLabel, outKey: "commands"},
"skill": {mode: modeMapByLabel, outKey: "skills"},
// Top-level reusable toolset definitions: `toolsets "name" { ... }` becomes
// toolsets: { name: { ... } }. Distinct from the agent-level `toolset`
// (singular) block, which aggregates into a list under the same key.
+7
View File
@@ -106,3 +106,10 @@ func (runConfig *RuntimeConfig) CLIHooks() *latest.HooksConfig {
runConfig.HookStop,
)
}
// MergeAgentHooks appends inherited hooks to each agent before validation and preflight.
func MergeAgentHooks(cfg *latest.Config, inherited *latest.HooksConfig) {
for i := range cfg.Agents {
cfg.Agents[i].Hooks = MergeHooks(cfg.Agents[i].Hooks, inherited)
}
}
+167
View File
@@ -0,0 +1,167 @@
package latest
import (
"cmp"
"errors"
"fmt"
"math"
"net/url"
"strings"
)
// EvaluatorConfig defines a reusable assessment, independent of its consumers' policies.
type EvaluatorConfig struct {
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"base_url,omitempty"`
TokenKey string `json:"token_key,omitempty"`
Type string `json:"type"`
Instructions string `json:"instructions"`
Choices map[string]string `json:"choices,omitempty"`
Levels []string `json:"levels,omitempty"`
Timeout Duration `json:"timeout,omitzero"`
}
// Validate checks an evaluator definition before provider resolution.
func (e EvaluatorConfig) Validate() error {
if strings.TrimSpace(e.Provider) == "" || strings.TrimSpace(e.Model) == "" {
return errors.New("provider and model are required")
}
if strings.TrimSpace(e.Instructions) == "" {
return errors.New("instructions are required")
}
if e.Timeout.Duration < 0 {
return errors.New("timeout must not be negative")
}
if e.BaseURL != "" {
u, err := url.Parse(e.BaseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return errors.New("base_url must be an HTTP(S) URL without credentials, query, or fragment")
}
}
switch e.Type {
case "boolean":
if len(e.Choices) != 0 || len(e.Levels) != 0 {
return errors.New("boolean evaluators cannot define choices or levels")
}
case "choice":
if len(e.Choices) < 2 || len(e.Choices) > 255 || len(e.Levels) != 0 {
return errors.New("choice evaluators require 2-255 choices and no levels")
}
for choice := range e.Choices {
if strings.TrimSpace(choice) == "" {
return errors.New("choice names must not be empty")
}
}
case "score":
if len(e.Levels) < 2 || len(e.Levels) > 10 || len(e.Choices) != 0 {
return errors.New("score evaluators require 2-10 levels and no choices")
}
for _, level := range e.Levels {
if strings.TrimSpace(level) == "" {
return errors.New("score levels must not be empty")
}
}
default:
return fmt.Errorf("unsupported evaluator type %q (expected boolean, choice, or score)", e.Type)
}
return nil
}
// Resolve applies connection defaults from a named provider without changing the definition.
func (e EvaluatorConfig) Resolve(providers map[string]ProviderConfig) (EvaluatorConfig, error) {
if p, ok := providers[e.Provider]; ok {
if p.Auth != nil || p.APIType != "" {
return e, errors.New("evaluator providers do not support auth or api_type")
}
e.Provider = p.Provider
e.BaseURL = cmp.Or(e.BaseURL, p.BaseURL)
e.TokenKey = cmp.Or(e.TokenKey, p.TokenKey)
}
if e.Provider != "typesafe" {
return e, fmt.Errorf("unsupported evaluator provider %q", e.Provider)
}
e.TokenKey = cmp.Or(e.TokenKey, "TYPESAFE_API_KEY")
return e, e.Validate()
}
// EvaluatorPolicy maps categorical assessments to tool-guard decisions.
// Boolean assessments use the keys "true" and "false".
type EvaluatorPolicy struct {
Decisions map[string]string `json:"decisions"`
MinProbability float64 `json:"min_probability"`
Fallback string `json:"fallback"`
}
// Validate checks a policy independently of its referenced evaluator.
func (p *EvaluatorPolicy) Validate() error {
if p == nil {
return errors.New("evaluator_policy is required")
}
if len(p.Decisions) == 0 {
return errors.New("evaluator_policy.decisions must not be empty")
}
if math.IsNaN(p.MinProbability) || math.IsInf(p.MinProbability, 0) || p.MinProbability <= 0 || p.MinProbability > 1 {
return errors.New("evaluator_policy.min_probability must be greater than 0 and at most 1")
}
if p.Fallback != "ask" && p.Fallback != "deny" {
return errors.New("evaluator_policy.fallback must be ask or deny")
}
for _, decision := range p.Decisions {
if decision != "allow" && decision != "ask" && decision != "deny" {
return errors.New("evaluator_policy decisions must be allow, ask, or deny")
}
}
return nil
}
func (p *EvaluatorPolicy) validateEvaluator(e EvaluatorConfig) error {
if err := p.Validate(); err != nil {
return err
}
if e.Type == "score" {
return errors.New("tool guards require a boolean or choice evaluator; score assessments are available through the Go API")
}
for key := range p.Decisions {
if e.Type == "boolean" {
if key != "true" && key != "false" {
return fmt.Errorf("unknown boolean outcome %q (expected true or false)", key)
}
} else if _, ok := e.Choices[key]; !ok {
return fmt.Errorf("unknown evaluator choice %q", key)
}
}
return nil
}
// ValidateEvaluators validates definitions and hook references. Provider aliases
// resolve at team load, after user-level providers have been merged.
func (t *Config) ValidateEvaluators() error {
for name, def := range t.Evaluators {
if strings.TrimSpace(name) == "" {
return errors.New("evaluator names must not be empty")
}
if err := def.Validate(); err != nil {
return fmt.Errorf("evaluators.%s: %w", name, err)
}
}
for _, a := range t.Agents {
for event, matchers := range a.Hooks.Events() {
for _, matcher := range matchers {
for _, hook := range matcher.Hooks {
if hook.Type != "evaluator" {
continue
}
def, ok := t.Evaluators[hook.Evaluator]
if !ok {
return fmt.Errorf("agents.%s.hooks.%s: unknown evaluator %q", a.Name, event, hook.Evaluator)
}
if err := hook.EvaluatorPolicy.validateEvaluator(def); err != nil {
return fmt.Errorf("agents.%s.hooks.%s: %w", a.Name, event, err)
}
}
}
}
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
package latest
import (
"encoding/json"
"math"
"strings"
"testing"
"time"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluatorConfigValidation(t *testing.T) {
t.Parallel()
base := EvaluatorConfig{Provider: "typesafe", Model: "jev-latest", Type: "boolean", Instructions: "Does this expose credentials?"}
for _, tc := range []struct {
name string
edit func(*EvaluatorConfig)
want string
}{
{"boolean", func(*EvaluatorConfig) {}, ""},
{"choice", func(e *EvaluatorConfig) {
e.Type = "choice"
e.Choices = map[string]string{"simple": "Routine", "complex": "Reasoning"}
}, ""},
{"score", func(e *EvaluatorConfig) { e.Type = "score"; e.Levels = []string{"Low", "High"} }, ""},
{"provider", func(e *EvaluatorConfig) { e.Provider = " " }, "provider and model"},
{"model", func(e *EvaluatorConfig) { e.Model = "" }, "provider and model"},
{"instructions", func(e *EvaluatorConfig) { e.Instructions = " " }, "instructions"},
{"type", func(e *EvaluatorConfig) { e.Type = "noul" }, "unsupported evaluator type"},
{"timeout", func(e *EvaluatorConfig) { e.Timeout.Duration = -time.Second }, "timeout"},
{"base url", func(e *EvaluatorConfig) { e.BaseURL = "https://user:secret@example.com" }, "base_url"},
{"url query", func(e *EvaluatorConfig) { e.BaseURL = "https://example.com?token=secret" }, "base_url"},
{"boolean criteria", func(e *EvaluatorConfig) { e.Levels = []string{"yes"} }, "cannot define"},
{"missing choices", func(e *EvaluatorConfig) { e.Type = "choice" }, "2-255"},
{"empty choice", func(e *EvaluatorConfig) { e.Type = "choice"; e.Choices = map[string]string{" ": "", "other": ""} }, "choice names"},
{"too many choices", func(e *EvaluatorConfig) {
e.Type = "choice"
e.Choices = map[string]string{}
for i := range 256 {
e.Choices[strings.Repeat("x", i+1)] = ""
}
}, "2-255"},
{"missing levels", func(e *EvaluatorConfig) { e.Type = "score" }, "2-10"},
{"empty level", func(e *EvaluatorConfig) { e.Type = "score"; e.Levels = []string{"low", " "} }, "levels must not"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := base
tc.edit(&e)
err := e.Validate()
if tc.want == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.want)
}
})
}
}
func TestEvaluatorResolve(t *testing.T) {
t.Parallel()
cfg := EvaluatorConfig{Provider: "corp", Model: "jev-latest", Type: "boolean", Instructions: "Assess risk"}
providers := map[string]ProviderConfig{"corp": {Provider: "typesafe", BaseURL: "https://example.com", TokenKey: "CORP_KEY"}}
resolved, err := cfg.Resolve(providers)
require.NoError(t, err)
assert.Equal(t, "typesafe", resolved.Provider)
assert.Equal(t, "CORP_KEY", resolved.TokenKey)
assert.Equal(t, "https://example.com", resolved.BaseURL)
assert.Equal(t, "corp", cfg.Provider)
assert.Empty(t, cfg.BaseURL)
cfg.BaseURL, cfg.TokenKey = "https://override.example.com", "OVERRIDE_KEY"
resolved, err = cfg.Resolve(providers)
require.NoError(t, err)
assert.Equal(t, cfg.BaseURL, resolved.BaseURL)
assert.Equal(t, cfg.TokenKey, resolved.TokenKey)
_, err = cfg.Resolve(nil)
require.ErrorContains(t, err, "unsupported evaluator provider")
providers["corp"] = ProviderConfig{Provider: "typesafe", APIType: "openai_responses"}
_, err = cfg.Resolve(providers)
require.ErrorContains(t, err, "do not support")
cfg.Provider, cfg.TokenKey = "typesafe", ""
resolved, err = cfg.Resolve(nil)
require.NoError(t, err)
assert.Equal(t, "TYPESAFE_API_KEY", resolved.TokenKey)
}
func TestEvaluatorPolicyValidation(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
policy *EvaluatorPolicy
want string
}{
{"missing", nil, "required"},
{"empty decisions", &EvaluatorPolicy{}, "decisions"},
{"missing probability", &EvaluatorPolicy{Decisions: map[string]string{"true": "ask"}, Fallback: "ask"}, "min_probability"},
{"nan", &EvaluatorPolicy{Decisions: map[string]string{"true": "ask"}, MinProbability: math.NaN(), Fallback: "ask"}, "min_probability"},
{"infinite", &EvaluatorPolicy{Decisions: map[string]string{"true": "ask"}, MinProbability: math.Inf(1), Fallback: "ask"}, "min_probability"},
{"allow fallback", &EvaluatorPolicy{Decisions: map[string]string{"true": "ask"}, MinProbability: 0.9, Fallback: "allow"}, "fallback"},
{"invalid decision", &EvaluatorPolicy{Decisions: map[string]string{"true": "yes"}, MinProbability: 0.9, Fallback: "ask"}, "decisions"},
{"valid", &EvaluatorPolicy{Decisions: map[string]string{"true": "ask"}, MinProbability: 0.9, Fallback: "deny"}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := tc.policy.Validate()
if tc.want == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.want)
}
})
}
}
func TestEvaluatorConfigRoundTripAndReferences(t *testing.T) {
t.Parallel()
const src = `evaluators:
risk:
provider: typesafe
model: jev-latest
type: boolean
instructions: Does this expose credentials?
timeout: 2s
agents:
root:
model: openai/gpt-5-mini
hooks:
tool_guard:
- hooks:
- type: evaluator
evaluator: risk
evaluator_policy:
decisions: {"true": ask, "false": allow}
min_probability: 0.95
fallback: ask
`
var cfg Config
require.NoError(t, yaml.Unmarshal([]byte(src), &cfg))
assert.Equal(t, 2*time.Second, cfg.Evaluators["risk"].Timeout.Duration)
data, err := json.Marshal(cfg)
require.NoError(t, err)
var decoded Config
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, decoded.Validate())
assert.Equal(t, cfg.Evaluators, decoded.Evaluators)
for _, tc := range []struct{ name, old, replacement, want string }{
{"unknown reference", "evaluator: risk", "evaluator: missing", "unknown evaluator"},
{"wrong event", "tool_guard:", "pre_tool_use:", "only supported on tool_guard"},
{"wrong boolean key", `"true": ask`, `"yes": ask`, "unknown boolean outcome"},
{"wrong hook type", "type: evaluator", "type: command\n command: echo", "evaluator fields require"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var invalid Config
err := yaml.Unmarshal([]byte(strings.Replace(src, tc.old, tc.replacement, 1)), &invalid)
require.ErrorContains(t, err, tc.want)
})
}
}
+5
View File
@@ -60,6 +60,11 @@ func (h *HooksConfig) Validate() error {
}
}
for i, matcher := range matchers {
for _, hook := range matcher.Hooks {
if hook.Type == "evaluator" && event != "tool_guard" {
return fmt.Errorf("hooks.%s: evaluator hooks are only supported on tool_guard", event)
}
}
if contract.ToolMatched {
if err := matcher.validate(event, i); err != nil {
return err
+23 -7
View File
@@ -23,12 +23,13 @@ const Version = "16"
// Config represents the entire configuration file
type Config struct {
Version string `json:"version,omitempty"`
Agents Agents `json:"agents,omitempty"`
Providers map[string]ProviderConfig `json:"providers,omitempty"`
Models map[string]ModelConfig `json:"models,omitempty"`
MCPs map[string]MCPToolset `json:"mcps,omitempty"`
RAG map[string]RAGToolset `json:"rag,omitempty"`
Version string `json:"version,omitempty"`
Agents Agents `json:"agents,omitempty"`
Providers map[string]ProviderConfig `json:"providers,omitempty"`
Models map[string]ModelConfig `json:"models,omitempty"`
Evaluators map[string]EvaluatorConfig `json:"evaluators,omitempty"`
MCPs map[string]MCPToolset `json:"mcps,omitempty"`
RAG map[string]RAGToolset `json:"rag,omitempty"`
// Commands and Skills are reusable, named groups shared across agents.
// An agent opts into a group by listing its name in AgentConfig.UseCommands
// or AgentConfig.UseSkills; the group is merged into the agent during config
@@ -2937,12 +2938,17 @@ type HookDefinition struct {
// add_prompt_files, redact_secrets (see also the
// redact_secrets agent flag), and several others
// documented in pkg/hooks/builtins.
// - "evaluator": assess tool input using a named evaluator and a separate policy.
// - "model": ask an LLM and translate its reply into the hook's
// native output. See Model / Prompt / Schema. Used to
// implement "LLM as a judge" pre_tool_use hooks,
// turn-start summarizers, etc., with no Go code.
Type string `json:"type" yaml:"type"`
// Evaluator references a top-level assessment used by a tool_guard hook.
Evaluator string `json:"evaluator,omitempty" yaml:"evaluator,omitempty"`
EvaluatorPolicy *EvaluatorPolicy `json:"evaluator_policy,omitempty" yaml:"evaluator_policy,omitempty"`
// Command is the shell command (Type==command) or the builtin name
// (Type==builtin) to invoke.
Command string `json:"command,omitempty" yaml:"command,omitempty"`
@@ -3045,6 +3051,9 @@ func (h *HookDefinition) validate(prefix string, index int) error {
return fmt.Errorf("hooks.%s[%d]: type is required", prefix, index)
}
if h.Type != "evaluator" && (h.Evaluator != "" || h.EvaluatorPolicy != nil) {
return fmt.Errorf("hooks.%s[%d]: evaluator fields require type evaluator", prefix, index)
}
switch h.Type {
case "command":
if h.Command == "" {
@@ -3054,6 +3063,13 @@ func (h *HookDefinition) validate(prefix string, index int) error {
if h.Command == "" {
return fmt.Errorf("hooks.%s[%d]: command must name the builtin to invoke", prefix, index)
}
case "evaluator":
if strings.TrimSpace(h.Evaluator) == "" {
return fmt.Errorf("hooks.%s[%d]: evaluator is required", prefix, index)
}
if err := h.EvaluatorPolicy.Validate(); err != nil {
return fmt.Errorf("hooks.%s[%d]: %w", prefix, index, err)
}
case "model":
if h.Model == "" {
return fmt.Errorf("hooks.%s[%d]: model is required for model hooks (e.g. 'openai/gpt-4o-mini')", prefix, index)
@@ -3062,7 +3078,7 @@ func (h *HookDefinition) validate(prefix string, index int) error {
return fmt.Errorf("hooks.%s[%d]: prompt is required for model hooks", prefix, index)
}
default:
return fmt.Errorf("hooks.%s[%d]: unsupported hook type '%s' (supported: 'command', 'builtin', 'model')", prefix, index, h.Type)
return fmt.Errorf("hooks.%s[%d]: unsupported hook type '%s' (supported: 'command', 'builtin', 'model', 'evaluator')", prefix, index, h.Type)
}
return nil
+3
View File
@@ -19,6 +19,9 @@ func (t *Config) UnmarshalYAML(unmarshal func(any) error) error {
}
func (t *Config) Validate() error {
if err := t.ValidateEvaluators(); err != nil {
return err
}
if err := t.Budget.validate(); err != nil {
return fmt.Errorf("budget: %w", err)
}
+5
View File
@@ -29,6 +29,8 @@ const (
FeatureHarness Feature = "harness"
// FeatureHooks covers agent lifecycle hooks, which run host commands.
FeatureHooks Feature = "hooks"
// FeatureEvaluators permits outbound provider-backed assessments.
FeatureEvaluators Feature = "evaluators"
// FeatureSkills covers agents loading skills from disk or inline.
FeatureSkills Feature = "skills"
// FeatureToon covers toolsets using `toon` output encoding (see
@@ -62,6 +64,9 @@ func Requires(cfg *latest.Config) Requirements {
Features: map[Feature][]string{},
}
for _, name := range slices.Sorted(maps.Keys(cfg.Evaluators)) {
r.feature(FeatureEvaluators, "evaluators."+name)
}
for _, name := range slices.Sorted(maps.Keys(cfg.Models)) {
r.model(cfg, cfg.Models[name], "models."+name)
}
+2
View File
@@ -308,6 +308,8 @@ func TestSchemaMatchesGoTypes(t *testing.T) {
"AgentConfig": reflect.TypeFor[latest.AgentConfig](),
"FallbackConfig": reflect.TypeFor[latest.FallbackConfig](),
"HarnessConfig": reflect.TypeFor[latest.HarnessConfig](),
"EvaluatorConfig": reflect.TypeFor[latest.EvaluatorConfig](),
"EvaluatorPolicy": reflect.TypeFor[latest.EvaluatorPolicy](),
"ModelConfig": reflect.TypeFor[latest.ModelConfig](),
"Metadata": reflect.TypeFor[latest.Metadata](),
"ProviderConfig": reflect.TypeFor[latest.ProviderConfig](),
+56
View File
@@ -0,0 +1,56 @@
# Evaluators
Evaluators return typed assessments independently of chat models and tool-approval
policies. Callers decide what a probability, choice, or score means for their use
case.
Resolve a `latest.EvaluatorConfig` against the configured providers, then construct
one reusable client per team:
```go
resolved, err := cfg.Resolve(providers)
if err != nil {
return err
}
client, err := provider.New(ctx, resolved, env)
if err != nil {
return err
}
result, err := client.Evaluate(ctx, map[string]any{"message": message})
```
`Evaluate` accepts a string, JSON object, or JSON array. Null, numeric, boolean,
and non-JSON-serializable states are rejected before credentials are requested.
State is sent to the configured evaluator service; callers must select the data
appropriate for that service.
## TypeSafe
The initial backend posts one question named `evaluation` to `/v1/systemone`.
It requires an explicit model, uses `https://api.typesafe.ai` by default, and
retrieves `TYPESAFE_API_KEY` from the environment provider on each request. A
custom token key, base URL, and timeout can be configured. The default timeout
is 10 seconds and includes credential lookup, HTTP transfer, and response reads.
Requests respect context cancellation. Redirects are never followed, responses
are limited to 1 MiB, and errors omit credentials, input, and response bodies.
Requests are not automatically retried.
| Config type | TypeSafe question | Criteria | Result |
| --- | --- | --- | --- |
| `boolean` | `noul` | None | `Probability` in `[0, 1]` |
| `choice` | `choice` | `Choices`, a key-to-description map | `Choice` and `Probabilities` |
| `score` | `score` | `Levels`, ordered descriptions | `Score` and `Probabilities` |
Scores use zero-based level indices, with probability keys `"0"`, `"1"`, etc.
Probability maps must have exactly the configured keys and sum to one within
0.001. A selected choice must have a highest probability; ties are accepted.
Scores must lie between zero and the final level index, allowing 0.001 for
rounding. Returned values are not normalized or clamped.
`Result.Model` identifies the model returned by the service. `Result.Usage`
contains input and output token counts. Zero-valued probabilities, scores, and
confidence remain present through pointer fields. Missing or null confidence
stays `nil`; required probabilities and scores cannot be missing or null.
Run local tests with `go test -race ./pkg/evaluator/...`. Tests use fake
environment providers and local HTTP servers, not the TypeSafe API.
+27
View File
@@ -0,0 +1,27 @@
// Package evaluator provides typed assessments without applying decision policies.
package evaluator
import "context"
// Evaluator assesses a string, JSON object, or JSON array.
type Evaluator interface {
Evaluate(ctx context.Context, state any) (*Result, error)
}
// Result is an assessment; callers decide how its probabilities affect behavior.
type Result struct {
Type string `json:"type"`
Model string `json:"model"`
Probability *float64 `json:"probability,omitempty"`
Choice string `json:"choice,omitempty"`
Score *float64 `json:"score,omitempty"`
Probabilities map[string]float64 `json:"probabilities,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Usage Usage `json:"usage"`
}
// Usage records the tokens consumed by an evaluation.
type Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
+93
View File
@@ -0,0 +1,93 @@
// Package provider constructs independently configured evaluator clients.
package provider
import (
"context"
"encoding/json"
"errors"
"maps"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/evaluator"
"github.com/docker/docker-agent/pkg/httpclient"
)
const (
defaultBaseURL = "https://api.typesafe.ai"
defaultTimeout = 10 * time.Second
)
// New builds a reusable client from a resolved evaluator configuration.
// Credentials are obtained from env on each evaluation, not during construction.
func New(ctx context.Context, cfg latest.EvaluatorConfig, env environment.Provider) (evaluator.Evaluator, error) {
if err := cfg.Validate(); err != nil {
return nil, errors.New("invalid evaluator configuration")
}
if cfg.Provider != "typesafe" {
return nil, errors.New("unsupported evaluator provider")
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, errors.New("evaluator model is required")
}
if env == nil {
return nil, errors.New("evaluator environment provider is required")
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
u, err := url.Parse(baseURL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return nil, errors.New("evaluator base URL must be an HTTP(S) URL without credentials, query, or fragment")
}
timeout := cfg.Timeout.Duration
if timeout == 0 {
timeout = defaultTimeout
}
if cfg.TokenKey == "" {
cfg.TokenKey = "TYPESAFE_API_KEY"
}
question := typesafeQuestion{Type: cfg.Type, Instructions: cfg.Instructions}
var probabilityKeys []string
switch cfg.Type {
case "boolean":
question.Type = "noul"
case "choice":
question.Criteria = cfg.Choices
probabilityKeys = slices.Sorted(maps.Keys(cfg.Choices))
case "score":
question.Criteria = cfg.Levels
for i := range cfg.Levels {
probabilityKeys = append(probabilityKeys, strconv.Itoa(i))
}
}
questionJSON, err := json.Marshal(question)
if err != nil {
return nil, errors.New("invalid evaluator question")
}
client := httpclient.NewHTTPClient(ctx)
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &typesafe{
client: client,
env: env,
endpoint: strings.TrimRight(baseURL, "/") + "/v1/systemone",
tokenKey: cfg.TokenKey,
model: cfg.Model,
resultType: cfg.Type,
questionType: question.Type,
question: questionJSON,
probabilityKeys: probabilityKeys,
timeout: timeout,
}, nil
}
+202
View File
@@ -0,0 +1,202 @@
package provider
import (
"context"
"errors"
"math"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)
type environmentFunc func(context.Context, string) (string, bool)
func (f environmentFunc) Get(ctx context.Context, name string) (string, bool) {
return f(ctx, name)
}
func testConfig(kind string) latest.EvaluatorConfig {
cfg := latest.EvaluatorConfig{
Provider: "typesafe",
Model: "jev-test",
Type: kind,
Instructions: "Assess the state.",
}
switch kind {
case "choice":
cfg.Choices = map[string]string{"safe": "Safe to proceed", "unsafe": "Do not proceed"}
case "score":
cfg.Levels = []string{"Low", "Medium", "High"}
}
return cfg
}
func TestNewDefaults(t *testing.T) {
t.Parallel()
env := environmentFunc(func(context.Context, string) (string, bool) {
t.Error("credentials must not be resolved when the client is constructed")
return "", false
})
client, err := New(t.Context(), testConfig("boolean"), env)
require.NoError(t, err)
p := client.(*typesafe)
assert.Equal(t, "https://api.typesafe.ai/v1/systemone", p.endpoint)
assert.Equal(t, 10*time.Second, p.timeout)
assert.Equal(t, "TYPESAFE_API_KEY", p.tokenKey)
assert.ErrorIs(t, p.client.CheckRedirect(nil, nil), http.ErrUseLastResponse)
}
func TestNewCustomSettings(t *testing.T) {
t.Parallel()
cfg := testConfig("score")
cfg.BaseURL = "https://example.invalid/proxy/"
cfg.TokenKey = "CUSTOM_TOKEN"
cfg.Timeout.Duration = 2 * time.Second
client, err := New(t.Context(), cfg, environment.NewNoEnvProvider())
require.NoError(t, err)
p := client.(*typesafe)
assert.Equal(t, "https://example.invalid/proxy/v1/systemone", p.endpoint)
assert.Equal(t, 2*time.Second, p.timeout)
assert.Equal(t, "CUSTOM_TOKEN", p.tokenKey)
assert.Equal(t, []string{"0", "1", "2"}, p.probabilityKeys)
}
func TestNewRejectsInvalidConfig(t *testing.T) {
t.Parallel()
tests := map[string]func(*latest.EvaluatorConfig){
"unknown provider": func(c *latest.EvaluatorConfig) { c.Provider = "other" },
"unresolved provider": func(c *latest.EvaluatorConfig) { c.Provider = "custom-typesafe" },
"missing model": func(c *latest.EvaluatorConfig) { c.Model = "" },
"whitespace model": func(c *latest.EvaluatorConfig) { c.Model = " " },
"invalid type": func(c *latest.EvaluatorConfig) { c.Type = "noul" },
"missing instructions": func(c *latest.EvaluatorConfig) { c.Instructions = "" },
"negative timeout": func(c *latest.EvaluatorConfig) { c.Timeout.Duration = -time.Second },
"missing choices": func(c *latest.EvaluatorConfig) { c.Type = "choice" },
"missing levels": func(c *latest.EvaluatorConfig) { c.Type = "score" },
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
cfg := testConfig("boolean")
mutate(&cfg)
client, err := New(t.Context(), cfg, environment.NewNoEnvProvider())
require.Error(t, err)
assert.Nil(t, client)
})
}
}
func TestNewRejectsInvalidBaseURL(t *testing.T) {
t.Parallel()
for _, baseURL := range []string{
"relative/path", "ftp://example.invalid", "https://", "https://user:private-token@example.invalid",
"https://example.invalid?token=private-token", "https://example.invalid#private-token", "http://%",
} {
t.Run(baseURL, func(t *testing.T) {
t.Parallel()
cfg := testConfig("boolean")
cfg.BaseURL = baseURL
client, err := New(t.Context(), cfg, environment.NewNoEnvProvider())
require.Error(t, err)
assert.NotContains(t, err.Error(), "private-token")
assert.Nil(t, client)
})
}
}
func TestNewRejectsMissingEnvironment(t *testing.T) {
t.Parallel()
client, err := New(t.Context(), testConfig("boolean"), nil)
require.Error(t, err)
assert.Nil(t, client)
}
func TestEvaluateMissingKey(t *testing.T) {
t.Parallel()
for _, token := range []string{"", " "} {
t.Run("empty-token="+token, func(t *testing.T) {
t.Parallel()
client, err := New(t.Context(), testConfig("boolean"), environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": token}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "state")
require.ErrorContains(t, err, "API key is missing")
assert.Nil(t, result)
})
}
client, err := New(t.Context(), testConfig("boolean"), environment.NewNoEnvProvider())
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "state")
require.ErrorContains(t, err, "API key is missing")
assert.Nil(t, result)
}
func TestEvaluateCanceledBeforeLookup(t *testing.T) {
t.Parallel()
client, err := New(t.Context(), testConfig("boolean"), environmentFunc(func(context.Context, string) (string, bool) {
t.Error("canceled evaluation looked up credentials")
return "", false
}))
require.NoError(t, err)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result, err := client.Evaluate(ctx, "state")
require.ErrorIs(t, err, context.Canceled)
assert.Nil(t, result)
}
func TestEvaluateTimeoutIncludesKeyLookup(t *testing.T) {
t.Parallel()
cfg := testConfig("boolean")
cfg.Timeout.Duration = time.Millisecond
client, err := New(t.Context(), cfg, environmentFunc(func(ctx context.Context, _ string) (string, bool) {
<-ctx.Done()
return "", false
}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "state")
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Nil(t, result)
}
type invalidState struct{}
func (invalidState) MarshalJSON() ([]byte, error) {
return nil, errors.New("private-state")
}
func TestEvaluateRejectsInvalidState(t *testing.T) {
t.Parallel()
for name, state := range map[string]any{
"nil": nil, "nil map": map[string]string(nil), "nil slice": []any(nil),
"number": 1, "boolean": true, "channel": make(chan int), "marshal error": invalidState{},
"NaN": math.NaN(), "infinity": math.Inf(1),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
client, err := New(t.Context(), testConfig("boolean"), environmentFunc(func(context.Context, string) (string, bool) {
t.Error("invalid state looked up credentials")
return "", false
}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), state)
require.Error(t, err)
assert.NotContains(t, err.Error(), "private-state")
assert.Nil(t, result)
})
}
}
+219
View File
@@ -0,0 +1,219 @@
package provider
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/evaluator"
)
const (
maxResponseBytes = 1 << 20
// The API may round each probability and the weighted score independently.
roundingTolerance = 1e-3
)
type typesafe struct {
client *http.Client
env environment.Provider
endpoint string
tokenKey string
model string
resultType string
questionType string
question json.RawMessage
probabilityKeys []string
timeout time.Duration
}
type typesafeQuestion struct {
Type string `json:"type"`
Instructions string `json:"instructions"`
Criteria any `json:"criteria,omitempty"`
}
type typesafeRequest struct {
Model string `json:"model"`
State json.RawMessage `json:"state"`
Questions map[string]json.RawMessage `json:"questions"`
}
type typesafeResponse struct {
Model string `json:"model"`
Answers map[string]*typesafeAnswer `json:"answers"`
Usage evaluator.Usage `json:"usage"`
}
type typesafeAnswer struct {
Type string `json:"type"`
Noul *float64 `json:"noul"`
Choice *string `json:"choice"`
Score *float64 `json:"score"`
Probabilities map[string]*float64 `json:"probabilities"`
Confidence *float64 `json:"confidence"`
}
func (p *typesafe) Evaluate(ctx context.Context, state any) (*evaluator.Result, error) {
ctx, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel()
if err := ctx.Err(); err != nil {
return nil, err
}
rawState, err := json.Marshal(state)
if err != nil {
return nil, errors.New("evaluator state must be JSON-serializable")
}
if len(rawState) == 0 || (rawState[0] != '"' && rawState[0] != '{' && rawState[0] != '[') {
return nil, errors.New("evaluator state must be a string, object, or array")
}
payload, err := json.Marshal(typesafeRequest{
Model: p.model,
State: rawState,
Questions: map[string]json.RawMessage{"evaluation": p.question},
})
if err != nil {
return nil, errors.New("failed to encode evaluator request")
}
if err := ctx.Err(); err != nil {
return nil, err
}
token, ok := p.env.Get(ctx, p.tokenKey)
if err := ctx.Err(); err != nil {
return nil, err
}
if !ok || strings.TrimSpace(token) == "" {
return nil, errors.New("evaluator API key is missing")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(payload))
if err != nil {
return nil, errors.New("failed to construct evaluator request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, requestError(ctx, "evaluator request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("evaluator returned HTTP status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, requestError(ctx, "failed to read evaluator response")
}
if len(body) > maxResponseBytes {
return nil, errors.New("evaluator response exceeds size limit")
}
var response typesafeResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, errors.New("invalid evaluator response JSON")
}
return p.result(response)
}
// Preserve cancellation identity without exposing transport errors or URLs.
func requestError(ctx context.Context, message string) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("%s: %w", message, err)
}
return errors.New(message)
}
func (p *typesafe) result(response typesafeResponse) (*evaluator.Result, error) {
answer := response.Answers["evaluation"]
if answer == nil {
return nil, errors.New("evaluator response is missing the evaluation answer")
}
if answer.Type != p.questionType {
return nil, errors.New("evaluator answer type does not match the question")
}
if strings.TrimSpace(response.Model) == "" {
return nil, errors.New("evaluator response is missing the model")
}
if response.Usage.InputTokens < 0 || response.Usage.OutputTokens < 0 {
return nil, errors.New("evaluator response has invalid token usage")
}
if answer.Confidence != nil && !validProbability(answer.Confidence) {
return nil, errors.New("evaluator answer has invalid confidence")
}
result := &evaluator.Result{
Type: p.resultType,
Model: response.Model,
Confidence: answer.Confidence,
Usage: response.Usage,
}
if p.resultType == "boolean" {
if !validProbability(answer.Noul) {
return nil, errors.New("evaluator answer has missing or invalid probability")
}
result.Probability = answer.Noul
return result, nil
}
probabilities, err := p.probabilities(answer.Probabilities)
if err != nil {
return nil, err
}
result.Probabilities = probabilities
switch p.resultType {
case "choice":
if answer.Choice == nil {
return nil, errors.New("evaluator answer is missing the choice")
}
selected, ok := probabilities[*answer.Choice]
if !ok {
return nil, errors.New("evaluator answer contains an unknown choice")
}
for _, probability := range probabilities {
if probability > selected {
return nil, errors.New("evaluator choice is not a highest-probability option")
}
}
result.Choice = *answer.Choice
case "score":
if answer.Score == nil || math.IsNaN(*answer.Score) || math.IsInf(*answer.Score, 0) || *answer.Score < -roundingTolerance || *answer.Score > float64(len(p.probabilityKeys)-1)+roundingTolerance {
return nil, errors.New("evaluator answer has missing or invalid score")
}
result.Score = answer.Score
}
return result, nil
}
func (p *typesafe) probabilities(values map[string]*float64) (map[string]float64, error) {
if len(values) != len(p.probabilityKeys) {
return nil, errors.New("evaluator answer probability keys do not match the criteria")
}
probabilities := make(map[string]float64, len(values))
var sum float64
for _, key := range p.probabilityKeys {
probability := values[key]
if !validProbability(probability) {
return nil, errors.New("evaluator answer has missing or invalid probabilities")
}
probabilities[key] = *probability
sum += *probability
}
if math.Abs(sum-1) > roundingTolerance {
return nil, errors.New("evaluator answer probabilities do not sum to one")
}
return probabilities, nil
}
func validProbability(value *float64) bool {
return value != nil && !math.IsNaN(*value) && *value >= 0 && *value <= 1
}
+441
View File
@@ -0,0 +1,441 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/environment"
)
func responseBody(answer string) string {
return `{"model":"jev-resolved","answers":{"evaluation":` + answer + `},"usage":{"input_tokens":12,"output_tokens":3}}`
}
func TestEvaluatePrimitives(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind string
state any
answer string
question string
wantResult string
}{
{
name: "boolean zero", kind: "boolean", state: "plain text",
answer: `{"type":"noul","noul":0}`,
question: `{"type":"noul","instructions":"Assess the state."}`,
wantResult: `{"type":"boolean","model":"jev-resolved","probability":0,"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "boolean one", kind: "boolean", state: "",
answer: `{"type":"noul","noul":1,"confidence":null}`,
question: `{"type":"noul","instructions":"Assess the state."}`,
wantResult: `{"type":"boolean","model":"jev-resolved","probability":1,"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "choice", kind: "choice", state: map[string]any{"tool": "shell", "args": []string{"echo", "hello"}},
answer: `{"type":"choice","choice":"safe","probabilities":{"safe":1,"unsafe":0},"confidence":1}`,
question: `{"type":"choice","instructions":"Assess the state.","criteria":{"safe":"Safe to proceed","unsafe":"Do not proceed"}}`,
wantResult: `{"type":"choice","model":"jev-resolved","choice":"safe","probabilities":{"safe":1,"unsafe":0},"confidence":1,"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "tied choice zero confidence", kind: "choice", state: map[string]any{},
answer: `{"type":"choice","choice":"unsafe","probabilities":{"safe":0.5,"unsafe":0.5},"confidence":0}`,
question: `{"type":"choice","instructions":"Assess the state.","criteria":{"safe":"Safe to proceed","unsafe":"Do not proceed"}}`,
wantResult: `{"type":"choice","model":"jev-resolved","choice":"unsafe","probabilities":{"safe":0.5,"unsafe":0.5},"confidence":0,"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "score zero", kind: "score", state: []any{"first", map[string]string{"message": "second"}},
answer: `{"type":"score","score":0,"probabilities":{"0":1,"1":0,"2":0},"confidence":0}`,
question: `{"type":"score","instructions":"Assess the state.","criteria":["Low","Medium","High"]}`,
wantResult: `{"type":"score","model":"jev-resolved","score":0,"probabilities":{"0":1,"1":0,"2":0},"confidence":0,"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "score rounded", kind: "score", state: []any{},
answer: `{"type":"score","score":1,"probabilities":{"0":0.3333,"1":0.3333,"2":0.3333},"legend":{"0":"Low","1":"Medium","2":"High"}}`,
question: `{"type":"score","instructions":"Assess the state.","criteria":["Low","Medium","High"]}`,
wantResult: `{"type":"score","model":"jev-resolved","score":1,"probabilities":{"0":0.3333,"1":0.3333,"2":0.3333},"usage":{"input_tokens":12,"output_tokens":3}}`,
},
{
name: "score maximum", kind: "score", state: json.RawMessage(` ["raw JSON"] `),
answer: `{"type":"score","score":2,"probabilities":{"0":0,"1":0,"2":1}}`,
question: `{"type":"score","instructions":"Assess the state.","criteria":["Low","Medium","High"]}`,
wantResult: `{"type":"score","model":"jev-resolved","score":2,"probabilities":{"0":0,"1":0,"2":1},"usage":{"input_tokens":12,"output_tokens":3}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/proxy/v1/systemone", r.URL.Path)
assert.Equal(t, "Bearer test-secret", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.NotEmpty(t, r.Header.Get("User-Agent"))
var request typesafeRequest
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) {
return
}
assert.Equal(t, "jev-test", request.Model)
assert.Len(t, request.Questions, 1)
assert.JSONEq(t, tt.question, string(request.Questions["evaluation"]))
stateJSON, err := json.Marshal(tt.state)
assert.NoError(t, err)
assert.JSONEq(t, string(stateJSON), string(request.State))
_, err = io.WriteString(w, responseBody(tt.answer))
assert.NoError(t, err)
}))
defer server.Close()
cfg := testConfig(tt.kind)
cfg.BaseURL = server.URL + "/proxy/"
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "test-secret"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), tt.state)
require.NoError(t, err)
require.NotNil(t, result)
resultJSON, err := json.Marshal(result)
require.NoError(t, err)
assert.JSONEq(t, tt.wantResult, string(resultJSON))
assert.EqualValues(t, 1, requests.Load())
})
}
}
func TestEvaluateRejectsInvalidAnswers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind string
answer string
}{
{"null answer", "boolean", `null`},
{"empty answer", "boolean", `{}`},
{"missing type", "boolean", `{"noul":0.5}`},
{"wrong type", "boolean", `{"type":"choice","noul":0.5}`},
{"missing noul", "boolean", `{"type":"noul"}`},
{"null noul", "boolean", `{"type":"noul","noul":null}`},
{"string noul", "boolean", `{"type":"noul","noul":"private-state"}`},
{"negative noul", "boolean", `{"type":"noul","noul":-0.1}`},
{"large noul", "boolean", `{"type":"noul","noul":1.1}`},
{"overflow noul", "boolean", `{"type":"noul","noul":1e400}`},
{"bad confidence", "boolean", `{"type":"noul","noul":0.5,"confidence":-0.1}`},
{"large confidence", "boolean", `{"type":"noul","noul":0.5,"confidence":1.1}`},
{"missing choice", "choice", `{"type":"choice","probabilities":{"safe":1,"unsafe":0}}`},
{"null choice", "choice", `{"type":"choice","choice":null,"probabilities":{"safe":1,"unsafe":0}}`},
{"unknown choice", "choice", `{"type":"choice","choice":"private-state","probabilities":{"safe":1,"unsafe":0}}`},
{"not highest choice", "choice", `{"type":"choice","choice":"unsafe","probabilities":{"safe":0.8,"unsafe":0.2}}`},
{"missing probabilities", "choice", `{"type":"choice","choice":"safe"}`},
{"null probabilities", "choice", `{"type":"choice","choice":"safe","probabilities":null}`},
{"missing key", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1}}`},
{"extra key", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1,"unsafe":0,"extra":0}}`},
{"replaced key", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1,"extra":0}}`},
{"null probability", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1,"unsafe":null}}`},
{"negative probability", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1,"unsafe":-0.1}}`},
{"large probability", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":1.1,"unsafe":0}}`},
{"bad sum", "choice", `{"type":"choice","choice":"safe","probabilities":{"safe":0.5,"unsafe":0.1}}`},
{"missing score", "score", `{"type":"score","probabilities":{"0":1,"1":0,"2":0}}`},
{"null score", "score", `{"type":"score","score":null,"probabilities":{"0":1,"1":0,"2":0}}`},
{"negative score", "score", `{"type":"score","score":-1,"probabilities":{"0":1,"1":0,"2":0}}`},
{"large score", "score", `{"type":"score","score":3,"probabilities":{"0":0,"1":0,"2":1}}`},
{"string score", "score", `{"type":"score","score":"private-state","probabilities":{"0":1,"1":0,"2":0}}`},
{"score level names", "score", `{"type":"score","score":1,"probabilities":{"Low":0,"Medium":1,"High":0}}`},
{"score wrong indices", "score", `{"type":"score","score":1,"probabilities":{"1":1,"2":0,"3":0}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := io.WriteString(w, responseBody(tt.answer))
assert.NoError(t, err)
}))
defer server.Close()
cfg := testConfig(tt.kind)
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "private-state")
require.Error(t, err)
assert.Nil(t, result)
assert.NotContains(t, err.Error(), "private-state")
assert.NotContains(t, err.Error(), "private-token")
})
}
}
func TestEvaluateInvalidResponses(t *testing.T) {
t.Parallel()
for name, body := range map[string]string{
"invalid json": "private-state", "null response": "null", "empty object": "{}",
"missing answer": `{"model":"jev","answers":{"other":{"type":"noul","noul":0}}}`,
"missing model": `{"answers":{"evaluation":{"type":"noul","noul":0}}}`,
"trailing json": responseBody(`{"type":"noul","noul":0}`) + `{}`,
"negative usage": `{"model":"jev","answers":{"evaluation":{"type":"noul","noul":0}},"usage":{"input_tokens":-1}}`,
"oversized": strings.Repeat(" ", maxResponseBytes) + responseBody(`{"type":"noul","noul":0}`),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, body)
}))
defer server.Close()
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "private-state")
require.Error(t, err)
assert.Nil(t, result)
assert.NotContains(t, err.Error(), "private-state")
})
}
}
func TestScoreAllowsBoundaryRounding(t *testing.T) {
t.Parallel()
for _, score := range []float64{-0.0001, 2.0001} {
t.Run(strconv.FormatFloat(score, 'g', -1, 64), func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := io.WriteString(w, responseBody(fmt.Sprintf(`{"type":"score","score":%g,"probabilities":{"0":0,"1":0,"2":1}}`, score)))
assert.NoError(t, err)
}))
defer server.Close()
cfg := testConfig("score")
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "state")
require.NoError(t, err)
require.NotNil(t, result.Score)
assert.InDelta(t, score, *result.Score, 1e-10)
})
}
}
func TestEvaluateTruncatedResponse(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "1000")
_, err := io.WriteString(w, "private-state")
assert.NoError(t, err)
}))
defer server.Close()
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "state")
require.ErrorContains(t, err, "failed to read evaluator response")
assert.NotContains(t, err.Error(), "private-state")
assert.Nil(t, result)
}
func TestEvaluateHTTPFailures(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusNoContent, http.StatusUnauthorized, http.StatusUnprocessableEntity, http.StatusTooManyRequests, http.StatusInternalServerError} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = io.WriteString(w, "private-state private-token")
}))
defer server.Close()
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "private-state")
require.ErrorContains(t, err, strconv.Itoa(status))
assert.Nil(t, result)
assert.NotContains(t, err.Error(), "private-state")
assert.NotContains(t, err.Error(), "private-token")
})
}
}
func TestEvaluateDoesNotFollowRedirects(t *testing.T) {
t.Parallel()
var redirected atomic.Int64
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
redirected.Add(1)
}))
defer target.Close()
for _, status := range []int{http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/private-state", status)
}))
defer server.Close()
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "private-state")
require.Error(t, err)
assert.Nil(t, result)
assert.NotContains(t, err.Error(), "private-state")
})
}
assert.Zero(t, redirected.Load())
}
func TestEvaluateCancellationAndTimeout(t *testing.T) {
t.Parallel()
for _, phase := range []string{"headers", "body"} {
for _, cancelRequest := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/cancel=%t", phase, cancelRequest), func(t *testing.T) {
t.Parallel()
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
if phase == "body" {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
}
close(started)
select {
case <-r.Context().Done():
case <-release:
}
}))
defer server.Close()
defer close(release)
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
if !cancelRequest {
cfg.Timeout.Duration = 100 * time.Millisecond
}
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token"}))
require.NoError(t, err)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := client.Evaluate(ctx, "private-state")
done <- err
}()
if cancelRequest {
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("request did not reach test server")
}
cancel()
}
select {
case err := <-done:
if cancelRequest {
require.ErrorIs(t, err, context.Canceled)
} else {
require.ErrorIs(t, err, context.DeadlineExceeded)
}
case <-time.After(5 * time.Second):
t.Fatal("evaluation ignored cancellation or timeout")
}
})
}
}
}
func TestEvaluateConcurrentRequestCredentials(t *testing.T) {
t.Parallel()
type credentialKey struct{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request typesafeRequest
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) {
return
}
var credential string
if !assert.NoError(t, json.Unmarshal(request.State, &credential)) {
return
}
assert.Equal(t, "Bearer "+credential, r.Header.Get("Authorization"))
_, err := io.WriteString(w, responseBody(`{"type":"noul","noul":0}`))
assert.NoError(t, err)
}))
defer server.Close()
cfg := testConfig("boolean")
cfg.BaseURL = server.URL
cfg.TokenKey = "SESSION_TOKEN"
client, err := New(t.Context(), cfg, environmentFunc(func(ctx context.Context, name string) (string, bool) {
assert.Equal(t, "SESSION_TOKEN", name)
value, ok := ctx.Value(credentialKey{}).(string)
return value, ok
}))
require.NoError(t, err)
var wg sync.WaitGroup
for i := range 12 {
wg.Go(func() {
credential := fmt.Sprintf("session-%d", i)
ctx := context.WithValue(t.Context(), credentialKey{}, credential)
result, err := client.Evaluate(ctx, credential)
if assert.NoError(t, err) {
assert.NotNil(t, result)
}
})
}
wg.Wait()
}
func TestNewSnapshotsCriteria(t *testing.T) {
t.Parallel()
for _, kind := range []string{"choice", "score"} {
t.Run(kind, func(t *testing.T) {
t.Parallel()
cfg := testConfig(kind)
client, err := New(t.Context(), cfg, environment.NewNoEnvProvider())
require.NoError(t, err)
p := client.(*typesafe)
before := string(p.question)
if kind == "choice" {
cfg.Choices["safe"] = "changed"
delete(cfg.Choices, "unsafe")
} else {
cfg.Levels[0] = "changed"
}
assert.Equal(t, before, string(p.question))
})
}
}
func TestEvaluateTransportErrorRedaction(t *testing.T) {
t.Parallel()
cfg := testConfig("boolean")
cfg.BaseURL = "http://127.0.0.1:1/private-state"
client, err := New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "private-token\n"}))
require.NoError(t, err)
result, err := client.Evaluate(t.Context(), "private-state")
require.Error(t, err)
assert.Nil(t, result)
assert.NotContains(t, err.Error(), "private-state")
assert.NotContains(t, err.Error(), "private-token")
}
+2
View File
@@ -34,4 +34,6 @@ const (
// hook's native [Output] shape. It is registered by the runtime
// because it depends on the runtime's model provider stack.
HookTypeModel HookType = "model"
// HookTypeEvaluator applies a policy to a typed tool-guard assessment.
HookTypeEvaluator HookType = "evaluator"
)
+16 -12
View File
@@ -10,6 +10,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config/latest"
)
// Identical definitions collapse; different builtin arguments remain distinct.
@@ -52,18 +54,20 @@ func TestHookIdentityIncludesEveryField(t *testing.T) {
OnError: "warn", Model: "test/first", Prompt: "first", Schema: "first",
}
changes := map[string]func(*Hook){
"Name": func(h *Hook) { h.Name = "other" },
"Type": func(h *Hook) { h.Type = HookTypeCommand },
"Command": func(h *Hook) { h.Command = "other" },
"Args": func(h *Hook) { h.Args = []string{"other"} },
"Timeout": func(h *Hook) { h.Timeout = 10 },
"Env": func(h *Hook) { h.Env = map[string]string{"PROFILE": "other"} },
"WorkingDir": func(h *Hook) { h.WorkingDir = "other" },
"OnError": func(h *Hook) { h.OnError = "block" },
"Model": func(h *Hook) { h.Model = "test/other" },
"Prompt": func(h *Hook) { h.Prompt = "other" },
"Schema": func(h *Hook) { h.Schema = "other" },
"StrictOutput": func(h *Hook) { h.StrictOutput = true },
"Evaluator": func(h *Hook) { h.Evaluator = "other" },
"EvaluatorPolicy": func(h *Hook) { h.EvaluatorPolicy = &latest.EvaluatorPolicy{Fallback: "ask"} },
"Name": func(h *Hook) { h.Name = "other" },
"Type": func(h *Hook) { h.Type = HookTypeCommand },
"Command": func(h *Hook) { h.Command = "other" },
"Args": func(h *Hook) { h.Args = []string{"other"} },
"Timeout": func(h *Hook) { h.Timeout = 10 },
"Env": func(h *Hook) { h.Env = map[string]string{"PROFILE": "other"} },
"WorkingDir": func(h *Hook) { h.WorkingDir = "other" },
"OnError": func(h *Hook) { h.OnError = "block" },
"Model": func(h *Hook) { h.Model = "test/other" },
"Prompt": func(h *Hook) { h.Prompt = "other" },
"Schema": func(h *Hook) { h.Schema = "other" },
"StrictOutput": func(h *Hook) { h.StrictOutput = true },
}
// Adding a config field must also extend identity and its coverage.
for field := range reflect.TypeFor[Hook]().Fields() {
+134
View File
@@ -0,0 +1,134 @@
package hooks
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"strconv"
"strings"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/evaluator"
)
// NewEvaluatorFactory returns a tool-guard factory backed by named evaluators.
// lookup resolves (agent name, evaluator name) when the hook runs.
// Register it under [HookTypeEvaluator] in the runtime's hook registry.
func NewEvaluatorFactory(lookup func(agentName, evaluatorName string) (evaluator.Evaluator, bool)) HandlerFactory {
return func(_ HandlerEnv, hook Hook) (Handler, error) {
if strings.TrimSpace(hook.Evaluator) == "" {
return nil, errors.New("evaluator hook requires a non-empty evaluator reference")
}
if err := hook.EvaluatorPolicy.Validate(); err != nil {
return nil, fmt.Errorf("evaluator hook: %w", err)
}
if lookup == nil {
return nil, errors.New("evaluator hook: no evaluator lookup configured")
}
policy := *hook.EvaluatorPolicy
policy.Decisions = maps.Clone(policy.Decisions)
return &evaluatorHandler{name: hook.Evaluator, lookup: lookup, policy: policy}, nil
}
}
type evaluatorHandler struct {
name string
lookup func(agentName, evaluatorName string) (evaluator.Evaluator, bool)
policy latest.EvaluatorPolicy
}
func (h *evaluatorHandler) Run(ctx context.Context, input []byte) (HandlerResult, error) {
var in Input
if err := json.Unmarshal(input, &in); err != nil {
return HandlerResult{ExitCode: -1}, fmt.Errorf("decode hook input: %w", err)
}
if in.HookEventName != EventToolGuard {
return HandlerResult{ExitCode: -1}, errors.New("evaluator hooks are only supported on tool_guard")
}
client, ok := h.lookup(in.AgentName, h.name)
if !ok || client == nil {
return HandlerResult{ExitCode: -1}, fmt.Errorf("evaluator hook: unknown evaluator %q for agent %q", h.name, in.AgentName)
}
// Do not send session identifiers, history, or unrelated hook fields.
result, err := client.Evaluate(ctx, map[string]any{
"tool_name": in.ToolName,
"tool_input": in.ToolInput,
"tool_category": in.ToolCategory,
})
if err != nil {
return HandlerResult{ExitCode: -1}, fmt.Errorf("evaluator %q: %w", h.name, err)
}
choice, probability, err := evaluatorOutcome(result)
if err != nil {
return HandlerResult{ExitCode: -1}, fmt.Errorf("evaluator %q: %w", h.name, err)
}
decision := h.policy.Fallback
reason := "Evaluator policy fallback applied."
if mapped, ok := h.policy.Decisions[choice]; ok && probability >= h.policy.MinProbability {
decision = mapped
reason = "Evaluator policy matched the selected outcome."
}
metadata := map[string]string{
"evaluator": h.name,
"evaluator_type": result.Type,
"evaluator_choice": choice,
"evaluator_probability": strconv.FormatFloat(probability, 'g', -1, 64),
}
if result.Model != "" {
metadata["evaluator_model"] = result.Model
}
return HandlerResult{Output: &Output{HookSpecificOutput: &HookSpecificOutput{
HookEventName: EventToolGuard,
PermissionDecision: Decision(decision),
PermissionDecisionReason: reason,
Metadata: metadata,
}}}, nil
}
func evaluatorOutcome(result *evaluator.Result) (string, float64, error) {
if result == nil {
return "", 0, errors.New("missing evaluator result")
}
var choice string
var probability float64
switch result.Type {
case "boolean":
if result.Probability == nil {
return "", 0, errors.New("missing boolean probability")
}
probability = *result.Probability
case "choice":
if strings.TrimSpace(result.Choice) == "" {
return "", 0, errors.New("missing evaluator choice")
}
choice = result.Choice
var ok bool
probability, ok = result.Probabilities[choice]
if !ok {
return "", 0, errors.New("missing selected choice probability")
}
default:
return "", 0, errors.New("tool guards require a boolean or choice evaluator result")
}
if math.IsNaN(probability) || probability < 0 || probability > 1 {
return "", 0, errors.New("invalid evaluator probability")
}
if result.Type == "boolean" {
choice = strconv.FormatBool(probability >= 0.5)
if probability < 0.5 {
probability = 1 - probability
}
}
return choice, probability, nil
}
func sameEvaluatorPolicy(a, b *latest.EvaluatorPolicy) bool {
if a == nil || b == nil {
return a == b
}
return a.MinProbability == b.MinProbability && a.Fallback == b.Fallback && maps.Equal(a.Decisions, b.Decisions)
}
+413
View File
@@ -0,0 +1,413 @@
package hooks
import (
"context"
"errors"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/evaluator"
)
type evaluatorFunc func(context.Context, any) (*evaluator.Result, error)
func (f evaluatorFunc) Evaluate(ctx context.Context, state any) (*evaluator.Result, error) {
return f(ctx, state)
}
func evaluatorTestHook() Hook {
return Hook{
Type: HookTypeEvaluator,
Evaluator: "guard",
EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": "allow", "false": "deny", "safe": "allow", "unsafe": "deny"},
MinProbability: 0.8,
Fallback: "ask",
},
}
}
func evaluatorTestHandler(t *testing.T, client evaluator.Evaluator, hook Hook) Handler {
t.Helper()
factory := NewEvaluatorFactory(func(_, name string) (evaluator.Evaluator, bool) {
assert.Equal(t, "guard", name)
return client, name == "guard"
})
handler, err := factory(HandlerEnv{}, hook)
require.NoError(t, err)
return handler
}
func TestNewEvaluatorFactoryRejectsInvalidConfig(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
change func(*Hook)
want string
}{
{"missing reference", func(h *Hook) { h.Evaluator = "" }, "non-empty evaluator reference"},
{"blank reference", func(h *Hook) { h.Evaluator = " \t" }, "non-empty evaluator reference"},
{"missing policy", func(h *Hook) { h.EvaluatorPolicy = nil }, "evaluator_policy is required"},
{"empty decisions", func(h *Hook) { h.EvaluatorPolicy.Decisions = nil }, "decisions must not be empty"},
{"invalid decision", func(h *Hook) { h.EvaluatorPolicy.Decisions["true"] = "block" }, "decisions must be"},
{"zero threshold", func(h *Hook) { h.EvaluatorPolicy.MinProbability = 0 }, "min_probability"},
{"negative threshold", func(h *Hook) { h.EvaluatorPolicy.MinProbability = -0.1 }, "min_probability"},
{"threshold above one", func(h *Hook) { h.EvaluatorPolicy.MinProbability = 1.1 }, "min_probability"},
{"nan threshold", func(h *Hook) { h.EvaluatorPolicy.MinProbability = math.NaN() }, "min_probability"},
{"infinite threshold", func(h *Hook) { h.EvaluatorPolicy.MinProbability = math.Inf(1) }, "min_probability"},
{"missing fallback", func(h *Hook) { h.EvaluatorPolicy.Fallback = "" }, "fallback must be"},
{"allow fallback", func(h *Hook) { h.EvaluatorPolicy.Fallback = "allow" }, "fallback must be"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
hook := evaluatorTestHook()
tc.change(&hook)
factory := NewEvaluatorFactory(func(string, string) (evaluator.Evaluator, bool) {
t.Error("invalid config must be rejected before lookup")
return nil, false
})
handler, err := factory(HandlerEnv{}, hook)
require.ErrorContains(t, err, tc.want)
assert.Nil(t, handler)
})
}
}
func TestNewEvaluatorFactoryRejectsMissingLookup(t *testing.T) {
t.Parallel()
handler, err := NewEvaluatorFactory(nil)(HandlerEnv{}, evaluatorTestHook())
require.ErrorContains(t, err, "no evaluator lookup configured")
assert.Nil(t, handler)
}
func TestEvaluatorHandlerRejectsMissingEvaluator(t *testing.T) {
t.Parallel()
for _, found := range []bool{false, true} {
handler, err := NewEvaluatorFactory(func(agentName, name string) (evaluator.Evaluator, bool) {
assert.Equal(t, "child", agentName)
assert.Equal(t, "guard", name)
return nil, found
})(HandlerEnv{}, evaluatorTestHook())
require.NoError(t, err)
result, err := handler.Run(t.Context(), []byte(`{"hook_event_name":"tool_guard","agent_name":"child"}`))
require.ErrorContains(t, err, `unknown evaluator "guard" for agent "child"`)
assert.Equal(t, -1, result.ExitCode)
assert.Nil(t, result.Output)
}
}
func TestEvaluatorFactoryResolvesAgentScopeAtRun(t *testing.T) {
t.Parallel()
var lookups []string
factory := NewEvaluatorFactory(func(agentName, name string) (evaluator.Evaluator, bool) {
lookups = append(lookups, agentName)
assert.Equal(t, "guard", name)
return evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
probability := 0.0
if agentName == "root" {
probability = 1
}
return &evaluator.Result{Type: "boolean", Probability: &probability}, nil
}), true
})
for _, tc := range []struct {
agent string
decision Decision
}{
{"root", DecisionAllow},
{"child", DecisionDeny},
} {
before := len(lookups)
handler, err := factory(HandlerEnv{}, evaluatorTestHook())
require.NoError(t, err)
require.Len(t, lookups, before, "construction must not resolve an agent scope")
input, err := (&Input{HookEventName: EventToolGuard, AgentName: tc.agent}).ToJSON()
require.NoError(t, err)
result, err := handler.Run(t.Context(), input)
require.NoError(t, err)
require.Len(t, lookups, before+1, "lookup must happen during Run, not construction")
assert.Equal(t, tc.decision, result.Output.HookSpecificOutput.PermissionDecision)
}
assert.Equal(t, []string{"root", "child"}, lookups)
}
func TestEvaluatorHandlerPolicy(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
result evaluator.Result
threshold float64
fallback string
decision Decision
choice string
probability string
matched bool
}{
{
name: "boolean true at threshold", result: evaluator.Result{Type: "boolean", Probability: new(0.8)},
decision: DecisionAllow, choice: "true", probability: "0.8", matched: true,
},
{
name: "boolean false complements probability", result: evaluator.Result{Type: "boolean", Probability: new(0.2)},
decision: DecisionDeny, choice: "false", probability: "0.8", matched: true,
},
{
name: "boolean zero is a valid answer", result: evaluator.Result{Type: "boolean", Probability: new(0.0)}, threshold: 1,
decision: DecisionDeny, choice: "false", probability: "1", matched: true,
},
{
name: "boolean one", result: evaluator.Result{Type: "boolean", Probability: new(1.0)}, threshold: 1,
decision: DecisionAllow, choice: "true", probability: "1", matched: true,
},
{
name: "boolean tie selects true", result: evaluator.Result{Type: "boolean", Probability: new(0.5)}, threshold: 0.5,
decision: DecisionAllow, choice: "true", probability: "0.5", matched: true,
},
{
name: "boolean below threshold", result: evaluator.Result{Type: "boolean", Probability: new(0.79)},
decision: DecisionAsk, choice: "true", probability: "0.79",
},
{
name: "boolean deny fallback", result: evaluator.Result{Type: "boolean", Probability: new(0.5)}, fallback: "deny",
decision: DecisionDeny, choice: "true", probability: "0.5",
},
{
name: "choice ignores low confidence",
result: evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": 0.8, "unsafe": 0.2}, Confidence: new(0.1)},
decision: DecisionAllow, choice: "safe", probability: "0.8", matched: true,
},
{
name: "choice ignores high confidence",
result: evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": 0.79, "unsafe": 0.21}, Confidence: new(1.0)},
decision: DecisionAsk, choice: "safe", probability: "0.79",
},
{
name: "choice deny",
result: evaluator.Result{Type: "choice", Choice: "unsafe", Probabilities: map[string]float64{"safe": 0.1, "unsafe": 0.9}},
decision: DecisionDeny, choice: "unsafe", probability: "0.9", matched: true,
},
{
name: "unmapped outcome uses fallback",
result: evaluator.Result{Type: "choice", Choice: "unknown", Probabilities: map[string]float64{"safe": 0, "unknown": 1}},
decision: DecisionAsk, choice: "unknown", probability: "1",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
hook := evaluatorTestHook()
if tc.threshold != 0 {
hook.EvaluatorPolicy.MinProbability = tc.threshold
}
if tc.fallback != "" {
hook.EvaluatorPolicy.Fallback = tc.fallback
}
tc.result.Model = "test-model"
handler := evaluatorTestHandler(t, evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
return &tc.result, nil
}), hook)
res, err := handler.Run(t.Context(), []byte(`{"hook_event_name":"tool_guard","tool_input":{"secret":"do not echo"}}`))
require.NoError(t, err)
assert.Zero(t, res.ExitCode)
require.NotNil(t, res.Output)
out := res.Output.HookSpecificOutput
require.NotNil(t, out)
assert.Equal(t, EventToolGuard, out.HookEventName)
assert.Equal(t, tc.decision, out.PermissionDecision)
assert.Equal(t, map[string]string{
"evaluator": "guard", "evaluator_type": tc.result.Type, "evaluator_choice": tc.choice,
"evaluator_probability": tc.probability, "evaluator_model": "test-model",
}, out.Metadata)
if tc.matched {
assert.Equal(t, "Evaluator policy matched the selected outcome.", out.PermissionDecisionReason)
} else {
assert.Equal(t, "Evaluator policy fallback applied.", out.PermissionDecisionReason)
}
})
}
}
func TestEvaluatorHandlerRejectsInvalidResults(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
result *evaluator.Result
want string
}{
{"nil result", nil, "missing evaluator result"},
{"missing answer", &evaluator.Result{}, "boolean or choice"},
{"unknown type", &evaluator.Result{Type: "other"}, "boolean or choice"},
{"score rejected", &evaluator.Result{Type: "score", Score: new(1.0)}, "boolean or choice"},
{"boolean missing probability", &evaluator.Result{Type: "boolean"}, "missing boolean probability"},
{"boolean negative", &evaluator.Result{Type: "boolean", Probability: new(-0.1)}, "invalid evaluator probability"},
{"boolean above one", &evaluator.Result{Type: "boolean", Probability: new(1.1)}, "invalid evaluator probability"},
{"boolean nan", &evaluator.Result{Type: "boolean", Probability: new(math.NaN())}, "invalid evaluator probability"},
{"boolean infinite", &evaluator.Result{Type: "boolean", Probability: new(math.Inf(1))}, "invalid evaluator probability"},
{"boolean negative infinite", &evaluator.Result{Type: "boolean", Probability: new(math.Inf(-1))}, "invalid evaluator probability"},
{"missing choice", &evaluator.Result{Type: "choice", Probabilities: map[string]float64{"": 1}}, "missing evaluator choice"},
{"blank choice", &evaluator.Result{Type: "choice", Choice: " ", Probabilities: map[string]float64{" ": 1}}, "missing evaluator choice"},
{"missing probability map", &evaluator.Result{Type: "choice", Choice: "safe"}, "missing selected choice probability"},
{"missing choice probability", &evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"unsafe": 1}}, "missing selected choice probability"},
{"choice negative", &evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": -0.1}}, "invalid evaluator probability"},
{"choice above one", &evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": 1.1}}, "invalid evaluator probability"},
{"choice nan", &evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": math.NaN()}}, "invalid evaluator probability"},
{"choice infinite", &evaluator.Result{Type: "choice", Choice: "safe", Probabilities: map[string]float64{"safe": math.Inf(1)}}, "invalid evaluator probability"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
handler := evaluatorTestHandler(t, evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
return tc.result, nil
}), evaluatorTestHook())
res, err := handler.Run(t.Context(), []byte(`{"hook_event_name":"tool_guard"}`))
require.ErrorContains(t, err, tc.want)
assert.Nil(t, res.Output, "malformed results must never use the policy fallback")
assert.Equal(t, -1, res.ExitCode)
})
}
}
func TestEvaluatorHandlerForwardsOnlyToolStateAndContext(t *testing.T) {
t.Parallel()
type contextKey struct{}
ctx, cancel := context.WithTimeout(context.WithValue(t.Context(), contextKey{}, "context-value"), time.Minute)
defer cancel()
in := &Input{
HookEventName: EventToolGuard,
ToolName: "shell", ToolCategory: "filesystem", ToolInput: map[string]any{"cmd": "ls", "nested": map[string]any{"value": true}},
SessionID: "private-session", ParentSessionID: "private-parent", Cwd: "private-cwd", ToolUseID: "private-call",
AgentName: "private-agent", LastUserMessage: "private-message", Prompt: "private-prompt",
Messages: []chat.Message{{Role: chat.MessageRoleUser, Content: "private-history"}},
}
calls := 0
handler := evaluatorTestHandler(t, evaluatorFunc(func(gotCtx context.Context, state any) (*evaluator.Result, error) {
calls++
assert.Equal(t, ctx, gotCtx)
assert.Equal(t, map[string]any{
"tool_name": "shell", "tool_category": "filesystem", "tool_input": in.ToolInput,
}, state)
return &evaluator.Result{Type: "boolean", Probability: new(1.0)}, nil
}), evaluatorTestHook())
assert.Zero(t, calls, "construction must not evaluate")
body, err := in.ToJSON()
require.NoError(t, err)
res, err := handler.Run(ctx, body)
require.NoError(t, err)
assert.Equal(t, 1, calls)
assert.NotContains(t, res.Output.HookSpecificOutput.Metadata, "evaluator_model")
}
func TestEvaluatorHandlerRejectsNonGuardInput(t *testing.T) {
t.Parallel()
for _, input := range []string{
`{`, `null`, `{}`, `{"hook_event_name":"pre_tool_use"}`, `{"hook_event_name":"permission_request"}`, `{"hook_event_name":"turn_start"}`,
} {
t.Run(input, func(t *testing.T) {
t.Parallel()
handler := evaluatorTestHandler(t, evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
t.Error("invalid hook input must not reach the evaluator")
return nil, nil
}), evaluatorTestHook())
res, err := handler.Run(t.Context(), []byte(input))
require.Error(t, err)
assert.Equal(t, -1, res.ExitCode)
assert.Nil(t, res.Output)
})
}
}
func TestEvaluatorHandlerPropagatesCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
handler := evaluatorTestHandler(t, evaluatorFunc(func(ctx context.Context, _ any) (*evaluator.Result, error) {
return nil, ctx.Err()
}), evaluatorTestHook())
res, err := handler.Run(ctx, []byte(`{"hook_event_name":"tool_guard"}`))
require.ErrorIs(t, err, context.Canceled)
assert.Equal(t, -1, res.ExitCode)
assert.Nil(t, res.Output)
}
func TestEvaluatorFactorySnapshotsPolicy(t *testing.T) {
t.Parallel()
hook := evaluatorTestHook()
handler := evaluatorTestHandler(t, evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
return &evaluator.Result{Type: "boolean", Probability: new(0.9)}, nil
}), hook)
hook.EvaluatorPolicy.Decisions["true"] = "deny"
hook.EvaluatorPolicy.MinProbability = 1
hook.EvaluatorPolicy.Fallback = "deny"
res, err := handler.Run(t.Context(), []byte(`{"hook_event_name":"tool_guard"}`))
require.NoError(t, err)
assert.Equal(t, DecisionAllow, res.Output.HookSpecificOutput.PermissionDecision)
}
func TestEvaluatorHookExecutorFailsClosed(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
result *evaluator.Result
err error
}{
{"provider error", nil, errors.New("provider unavailable")},
{"partial answer and error", &evaluator.Result{Type: "boolean", Probability: new(1.0)}, errors.New("incomplete response")},
{"nil result", nil, nil},
{"missing probability", &evaluator.Result{Type: "boolean"}, nil},
{"timeout", nil, context.DeadlineExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
registry := NewRegistry()
registry.Register(HookTypeEvaluator, NewEvaluatorFactory(func(string, string) (evaluator.Evaluator, bool) {
return evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
return tc.result, tc.err
}), true
}))
hook := evaluatorTestHook()
hook.OnError = "ignore"
exec := NewExecutorWithRegistry(&Config{ToolGuard: []MatcherConfig{{Hooks: []Hook{hook}}}}, "", nil, registry)
res, err := exec.Dispatch(t.Context(), EventToolGuard, &Input{ToolName: "shell"})
require.NoError(t, err)
assert.False(t, res.Allowed)
assert.False(t, res.PermissionAllowed)
assert.Equal(t, -1, res.ExitCode)
assert.Empty(t, res.Decision, "provider failure must not become an ask fallback")
assert.Contains(t, res.Message, "tool_guard hook failed to execute")
})
}
}
func TestEvaluatorHookExecutorAllowIsAdvisory(t *testing.T) {
t.Parallel()
registry := NewRegistry()
registry.Register(HookTypeEvaluator, NewEvaluatorFactory(func(string, string) (evaluator.Evaluator, bool) {
return evaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
return &evaluator.Result{Type: "boolean", Probability: new(1.0)}, nil
}), true
}))
exec := NewExecutorWithRegistry(&Config{ToolGuard: []MatcherConfig{{Hooks: []Hook{evaluatorTestHook()}}}}, "", nil, registry)
res, err := exec.Dispatch(t.Context(), EventToolGuard, &Input{ToolName: "shell"})
require.NoError(t, err)
assert.True(t, res.Allowed)
assert.Equal(t, DecisionAllow, res.Decision)
assert.False(t, res.PermissionAllowed)
assert.Nil(t, res.ModifiedInput)
assert.Equal(t, "guard", res.Metadata["evaluator"])
}
+2
View File
@@ -296,6 +296,8 @@ func sameHook(a, b Hook) bool {
a.OnError == b.OnError &&
a.StrictOutput == b.StrictOutput &&
a.Model == b.Model &&
a.Evaluator == b.Evaluator &&
sameEvaluatorPolicy(a.EvaluatorPolicy, b.EvaluatorPolicy) &&
a.Prompt == b.Prompt &&
a.Schema == b.Schema
}
+357
View File
@@ -0,0 +1,357 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/evaluator"
evaluatorprovider "github.com/docker/docker-agent/pkg/evaluator/provider"
"github.com/docker/docker-agent/pkg/hooks"
"github.com/docker/docker-agent/pkg/permissions"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/teamloader"
"github.com/docker/docker-agent/pkg/tools"
)
func TestEvaluatorHookApprovalIntegration(t *testing.T) {
t.Parallel()
tests := []struct {
name string
decision string
yolo bool
denyRule bool
httpError bool
wantRun bool
wantBlock bool
wantMessage string
}{
{
name: "allow preserves yolo approval", decision: "allow", yolo: true, wantRun: true,
},
{
name: "allow cannot approve a strict session", decision: "allow",
wantMessage: "requires user confirmation but the session is non-interactive",
},
{
name: "allow cannot bypass a deny rule", decision: "allow", yolo: true, denyRule: true,
wantMessage: "denied by permissions configuration",
},
{
name: "ask overrides yolo without a human", decision: "ask", yolo: true,
wantMessage: "requires user confirmation but the session is non-interactive",
},
{
name: "deny overrides yolo", decision: "deny", yolo: true, wantBlock: true,
wantMessage: "tool_guard hook",
},
{
name: "HTTP failure closes the guard", decision: "allow", yolo: true, httpError: true, wantBlock: true,
wantMessage: "evaluator returned HTTP status 503",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/systemone", r.URL.Path)
assert.Equal(t, "Bearer evaluator-secret", r.Header.Get("Authorization"))
var payload struct {
State json.RawMessage `json:"state"`
}
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) {
return
}
assert.JSONEq(t, `{"tool_name":"the_tool","tool_input":{"cmd":"git status"},"tool_category":"test"}`, string(payload.State))
if tt.httpError {
http.Error(w, "private provider error: evaluator-secret", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{"model":"jev-resolved","answers":{"evaluation":{"type":"noul","noul":0.98}}}`)
assert.NoError(t, err)
}))
t.Cleanup(server.Close)
client, err := evaluatorprovider.New(t.Context(), latest.EvaluatorConfig{
Provider: "typesafe", Model: "jev", BaseURL: server.URL,
Type: "boolean", Instructions: "Is the proposed tool call safe?",
}, environment.NewMapEnvProvider(map[string]string{"TYPESAFE_API_KEY": "evaluator-secret"}))
require.NoError(t, err)
var executed bool
agentTools := recordingTool("the_tool", &executed)
agentTools[0].Category = "test"
root := agent.New("root", "private agent instructions",
agent.WithModel(&mockProvider{id: "test/mock-model", stream: &mockStream{}}),
agent.WithToolSets(newStubToolSet(nil, agentTools, nil)),
agent.WithHooks(&latest.HooksConfig{
ToolGuard: []latest.HookMatcherConfig{{
Matcher: "^the_tool$",
Hooks: []latest.HookDefinition{{
Type: hooks.HookTypeEvaluator, Evaluator: "safety",
EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": tt.decision, "false": "deny"},
MinProbability: 0.9, Fallback: "ask",
},
}},
}},
}),
)
var checker *permissions.Checker
if tt.denyRule {
checker = permissions.NewChecker(&latest.PermissionsConfig{Deny: []string{"the_tool"}})
}
tm := team.New(team.WithAgents(root), team.WithPermissions(checker),
team.WithEvaluators(map[string]evaluator.Evaluator{"safety": client}))
rt, err := NewLocalRuntime(t.Context(), tm, WithSessionCompaction(false), WithModelStore(mockModelStore{}))
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, rt.Close()) })
factory, ok := rt.hooksRegistry.Lookup(hooks.HookTypeEvaluator)
require.True(t, ok)
require.NotNil(t, factory)
require.True(t, rt.hooksExec(root).Has(hooks.EventToolGuard))
assert.Zero(t, requests.Load(), "constructing a runtime must not evaluate tools")
policy := session.SafetyPolicyStrict
if tt.yolo {
policy = session.SafetyPolicyAutonomous
}
sess := session.New(session.WithUserMessage("private conversation"),
session.WithSafetyPolicy(policy), session.WithNonInteractive(true))
require.Equal(t, tt.yolo, sess.ToolsApproved)
calls := []tools.ToolCall{{
ID: "call_1", Type: "function",
Function: tools.FunctionCall{Name: "the_tool", Arguments: `{"cmd":"git status"}`},
}}
eventCh := make(chan Event, 32)
rt.processToolCalls(t.Context(), sess, calls, agentTools, NewChannelSink(eventCh))
close(eventCh)
events := collectClosedEvents(eventCh)
assert.Equal(t, tt.wantRun, executed)
assert.EqualValues(t, 1, requests.Load(), "each tool call must evaluate exactly once")
assert.False(t, hasEventOfType[*ToolCallConfirmationEvent](events))
assert.Equal(t, tt.wantBlock, hasEventOfType[*HookBlockedEvent](events))
var responses []*ToolCallResponseEvent
for _, event := range events {
if response, ok := event.(*ToolCallResponseEvent); ok {
responses = append(responses, response)
}
}
require.Len(t, responses, 1)
require.NotNil(t, responses[0].Result)
assert.Equal(t, !tt.wantRun, responses[0].Result.IsError)
if tt.wantMessage != "" {
assert.Contains(t, responses[0].Response, tt.wantMessage)
}
assert.NotContains(t, responses[0].Response, "evaluator-secret")
assert.NotContains(t, responses[0].Response, "private provider error")
})
}
}
func TestEvaluatorHookImportedAgentScope(t *testing.T) {
t.Parallel()
for _, childEvaluator := range []string{"safety", "child_only"} {
t.Run(childEvaluator, func(t *testing.T) {
t.Parallel()
var parentCalls, childCalls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
probability := 1.0
switch r.URL.Path {
case "/parent/v1/systemone":
parentCalls.Add(1)
assert.Equal(t, "Bearer parent-secret", r.Header.Get("Authorization"))
case "/child/v1/systemone":
childCalls.Add(1)
assert.Equal(t, "Bearer child-secret", r.Header.Get("Authorization"))
probability = 0
default:
t.Errorf("unexpected evaluator URL: %s", r.URL.Path)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, err := fmt.Fprintf(w, `{"model":"jev","answers":{"evaluation":{"type":"noul","noul":%g}}}`, probability)
assert.NoError(t, err)
}))
t.Cleanup(server.Close)
const sourceYAML = `
evaluators:
%[1]s:
provider: typesafe
model: jev
base_url: %[2]s
token_key: %[3]s
type: boolean
instructions: Is this tool call safe?
agents:
root:
model: openai/gpt-4o
instruction: test
%[4]s
hooks:
tool_guard:
- hooks:
- type: evaluator
evaluator: %[1]s
evaluator_policy:
decisions: {"true": allow, "false": deny}
min_probability: 0.9
fallback: ask
`
parentYAML := fmt.Sprintf(sourceYAML, "safety", server.URL+"/parent", "PARENT_KEY", "sub_agents: [child:example/helper]")
childYAML := fmt.Sprintf(sourceYAML, childEvaluator, server.URL+"/child", "CHILD_KEY", "")
tm, err := teamloader.Load(t.Context(), config.NewBytesSource("parent.yaml", []byte(parentYAML)), &config.RuntimeConfig{
EnvProviderForTests: environment.NewMapEnvProvider(map[string]string{
"OPENAI_API_KEY": "fake-chat-key", "PARENT_KEY": "parent-secret", "CHILD_KEY": "child-secret",
}),
}, teamloader.WithProviderRegistry(testProviderRegistry()),
teamloader.WithStrict(config.FeatureHooks, config.FeatureEvaluators, config.FeatureExternalAgents),
teamloader.WithSourceResolver(func(ref string, _ environment.Provider) (config.Source, error) {
assert.Equal(t, "example/helper", ref)
return config.NewBytesSource("child.yaml", []byte(childYAML)), nil
}))
require.NoError(t, err)
root, err := tm.Agent("root")
require.NoError(t, err)
child, err := tm.Agent("child")
require.NoError(t, err)
require.Equal(t, []*agent.Agent{child}, root.SubAgents())
parentClient, ok := tm.Evaluator("safety")
require.True(t, ok)
childClient, ok := child.Evaluator(childEvaluator)
require.True(t, ok)
assert.NotSame(t, parentClient, childClient)
if childEvaluator == "child_only" {
_, ok := tm.Evaluator(childEvaluator)
assert.False(t, ok, "imported definitions must not be merged into the team scope")
}
var executed bool
agentTools := recordingTool("scoped_tool", &executed)
for _, a := range []*agent.Agent{root, child} {
agent.WithTools(agentTools...)(a)
}
rt, err := NewLocalRuntime(t.Context(), tm, WithModelStore(mockModelStore{}), WithSessionCompaction(false))
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, rt.Close()) })
assert.Zero(t, parentCalls.Load())
assert.Zero(t, childCalls.Load())
for _, a := range []*agent.Agent{root, child} {
executed = false
sess := session.New(session.WithAgentName(a.Name()), session.WithSafetyPolicy(session.SafetyPolicyAutonomous), session.WithNonInteractive(true))
calls := []tools.ToolCall{{ID: "call_1", Type: "function", Function: tools.FunctionCall{Name: "scoped_tool", Arguments: `{}`}}}
events := make(chan Event, 32)
rt.processToolCalls(t.Context(), sess, calls, agentTools, NewChannelSink(events))
close(events)
assert.Equal(t, a == root, executed, "child guard must use its own deny response")
collected := collectClosedEvents(events)
assert.Equal(t, a == child, hasEventOfType[*HookBlockedEvent](collected))
if a == child {
for _, event := range collected {
if blocked, ok := event.(*HookBlockedEvent); ok {
assert.Contains(t, blocked.Message, "Evaluator policy matched the selected outcome.")
}
}
}
}
assert.EqualValues(t, 1, parentCalls.Load())
assert.EqualValues(t, 1, childCalls.Load(), "child-only definitions must remain reachable after import")
})
}
}
type runtimeEvaluatorFunc func(context.Context, any) (*evaluator.Result, error)
func (f runtimeEvaluatorFunc) Evaluate(ctx context.Context, state any) (*evaluator.Result, error) {
return f(ctx, state)
}
func TestEvaluatorHookTeamFallbackOnlyForUnboundAgents(t *testing.T) {
t.Parallel()
for _, scope := range []string{"unbound", "nil", "empty", "other evaluator", "scoped"} {
t.Run(scope, func(t *testing.T) {
t.Parallel()
var teamCalls, scopedCalls atomic.Int64
teamClient := runtimeEvaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
teamCalls.Add(1)
return &evaluator.Result{Type: "boolean", Probability: new(1.0)}, nil
})
scopedClient := runtimeEvaluatorFunc(func(context.Context, any) (*evaluator.Result, error) {
scopedCalls.Add(1)
return &evaluator.Result{Type: "boolean", Probability: new(0.0)}, nil
})
root := agent.New("root", "test", agent.WithModel(&mockProvider{id: "test/model", stream: &mockStream{}}),
agent.WithHooks(&latest.HooksConfig{ToolGuard: []latest.HookMatcherConfig{{Hooks: []latest.HookDefinition{{
Type: hooks.HookTypeEvaluator, Evaluator: "safety", OnError: "ignore",
EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": "allow", "false": "deny"}, MinProbability: 0.9, Fallback: "ask",
},
}}}}}))
switch scope {
case "nil":
agent.WithEvaluators(nil)(root)
case "empty":
agent.WithEvaluators(map[string]evaluator.Evaluator{})(root)
case "other evaluator":
agent.WithEvaluators(map[string]evaluator.Evaluator{"other": scopedClient})(root)
case "scoped":
bindings := map[string]evaluator.Evaluator{"safety": scopedClient}
agent.WithEvaluators(bindings)(root)
bindings["safety"] = teamClient
}
assert.Equal(t, scope != "unbound", root.HasEvaluatorScope())
tm := team.New(team.WithAgents(root), team.WithEvaluators(map[string]evaluator.Evaluator{"safety": teamClient}))
rt, err := NewLocalRuntime(t.Context(), tm, WithModelStore(mockModelStore{}), WithSessionCompaction(false))
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, rt.Close()) })
res := rt.dispatchHook(t.Context(), root, hooks.EventToolGuard, &hooks.Input{ToolName: "the_tool"}, nil)
require.NotNil(t, res)
switch scope {
case "unbound":
assert.True(t, res.Allowed)
assert.Equal(t, hooks.DecisionAllow, res.Decision)
assert.EqualValues(t, 1, teamCalls.Load())
assert.Zero(t, scopedCalls.Load())
case "scoped":
assert.False(t, res.Allowed)
assert.Equal(t, hooks.DecisionDeny, res.Decision)
assert.Zero(t, teamCalls.Load())
assert.EqualValues(t, 1, scopedCalls.Load(), "agent bindings must be snapshotted")
default:
assert.False(t, res.Allowed)
assert.Equal(t, -1, res.ExitCode)
assert.Contains(t, res.Message, `unknown evaluator "safety" for agent "root"`)
assert.Zero(t, teamCalls.Load())
assert.Zero(t, scopedCalls.Load())
}
})
}
}
+11
View File
@@ -23,6 +23,7 @@ import (
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/evaluator"
"github.com/docker/docker-agent/pkg/harness"
"github.com/docker/docker-agent/pkg/hooks"
"github.com/docker/docker-agent/pkg/hooks/builtins"
@@ -780,6 +781,16 @@ func NewLocalRuntime(ctx context.Context, agents *team.Team, opts ...Opt) (*Loca
return nil, fmt.Errorf("register builtin hooks: %w", err)
}
registerModelHook(r.hooksRegistry, r.providerRegistry)
r.hooksRegistry.Register(hooks.HookTypeEvaluator, hooks.NewEvaluatorFactory(func(agentName, name string) (evaluator.Evaluator, bool) {
a, err := r.team.Agent(agentName)
if err != nil {
return nil, false
}
if a.HasEvaluatorScope() {
return a.Evaluator(name)
}
return r.team.Evaluator(name)
}))
// cache_response is registered here (not in pkg/hooks/builtins)
// because it needs to capture the runtime to resolve the agent
+42
View File
@@ -0,0 +1,42 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)
func TestEnvForAgentEvaluatorProviderDefaults(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "agent.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
evaluators:
risk:
provider: corporate
model: jev-latest
type: boolean
instructions: Assess risk.
agents:
root:
model: openai/gpt-5-mini
`), 0o600))
rc := &config.RuntimeConfig{}
rc.Providers = map[string]latest.ProviderConfig{"corporate": {Provider: "typesafe", TokenKey: "CORPORATE_KEY"}}
rc.GlobalHooks = &latest.HooksConfig{ToolGuard: latest.HookMatcherConfigs{{Hooks: latest.HookDefinitions{{
Type: "evaluator", Evaluator: "risk", EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": "ask"}, MinProbability: 0.9, Fallback: "ask",
},
}}}}}
flags, values := EnvForAgent(t.Context(), path, environment.NewMapEnvProvider(map[string]string{
"OPENAI_API_KEY": "chat-key", "CORPORATE_KEY": "evaluator-key",
}), nil, rc)
assert.Equal(t, []string{"-e", "CORPORATE_KEY"}, flags)
assert.Equal(t, []string{"CORPORATE_KEY=evaluator-key"}, values)
}
+20 -3
View File
@@ -287,12 +287,12 @@ var proxyManagedEnvVars = []string{
// - envVars: `KEY=VALUE` entries to set on the exec process environment
//
// Variables that Docker Desktop already proxies are skipped.
func EnvForAgent(ctx context.Context, agentRef string, env environment.Provider, flavors []string) (flags, envVars []string) {
func EnvForAgent(ctx context.Context, agentRef string, env environment.Provider, flavors []string, defaults ...*config.RuntimeConfig) (flags, envVars []string) {
if agentRef == "" {
return nil, nil
}
names, err := gatherAgentEnvVars(ctx, agentRef, env, flavors)
names, err := gatherAgentEnvVars(ctx, agentRef, env, flavors, defaults...)
if err != nil {
slog.DebugContext(ctx, "Failed to gather agent env vars for sandbox", "error", err)
return nil, nil
@@ -315,7 +315,7 @@ func EnvForAgent(ctx context.Context, agentRef string, env environment.Provider,
// gatherAgentEnvVars resolves the agent config and returns the list of
// environment variable names required by its models and tools.
func gatherAgentEnvVars(ctx context.Context, agentRef string, env environment.Provider, flavors []string) ([]string, error) {
func gatherAgentEnvVars(ctx context.Context, agentRef string, env environment.Provider, flavors []string, defaults ...*config.RuntimeConfig) ([]string, error) {
source, err := sources.Resolve(agentRef, env)
if err != nil {
return nil, fmt.Errorf("resolving agent: %w", err)
@@ -326,8 +326,25 @@ func gatherAgentEnvVars(ctx context.Context, agentRef string, env environment.Pr
return nil, fmt.Errorf("loading agent config: %w", err)
}
for _, rc := range defaults {
if rc == nil {
continue
}
config.MergeGlobalProviders(cfg, rc.Providers)
config.MergeAgentHooks(cfg, config.MergeHooks(rc.GlobalHooks, rc.CLIHooks()))
}
if err := cfg.ValidateEvaluators(); err != nil {
return nil, err
}
for name, def := range cfg.Evaluators {
if _, err := def.Resolve(cfg.Providers); err != nil {
return nil, fmt.Errorf("evaluator %q: %w", name, err)
}
}
var names []string
names = append(names, config.GatherEnvVarsForModels(ctx, cfg, env)...)
names = append(names, config.GatherEnvVarsForEvaluators(cfg)...)
toolNames, err := config.GatherEnvVarsForTools(ctx, cfg)
if err != nil {
+14
View File
@@ -4,16 +4,19 @@ import (
"context"
"errors"
"fmt"
"maps"
"strings"
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/evaluator"
"github.com/docker/docker-agent/pkg/permissions"
)
type Team struct {
agents []*agent.Agent
evaluators map[string]evaluator.Evaluator
permissions *permissions.Checker
// runtimeSafety is the config-wide safety-mode default declared under
// runtime.safety, retained so session constructors can apply it when
@@ -196,3 +199,14 @@ func (t *Team) RuntimeSafety() latest.SafetyMode {
func (t *Team) SetPermissions(checker *permissions.Checker) {
t.permissions = checker
}
// WithEvaluators installs reusable, provider-backed assessments for this team.
func WithEvaluators(evaluators map[string]evaluator.Evaluator) Opt {
return func(t *Team) { t.evaluators = maps.Clone(evaluators) }
}
// Evaluator returns the named assessment shared by the team's agents.
func (t *Team) Evaluator(name string) (evaluator.Evaluator, bool) {
e, ok := t.evaluators[name]
return e, ok
}
+301
View File
@@ -0,0 +1,301 @@
package teamloader
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)
const evaluatorTeamYAML = `
evaluators:
safety:
provider: risk_api
model: jev
type: choice
instructions: Classify the proposed tool call.
choices:
safe: Safe to proceed
unsafe: Do not proceed
unused:
provider: risk_api
model: jev
type: boolean
instructions: Is the state safe?
token_key: UNUSED_EVALUATOR_KEY
agents:
root:
model: openai/gpt-4o
instruction: test
hooks:
tool_guard:
- matcher: shell
hooks:
- type: evaluator
evaluator: safety
evaluator_policy:
decisions:
safe: allow
unsafe: deny
min_probability: 0.9
fallback: ask
`
func TestLoadEvaluatorsIntegration(t *testing.T) {
t.Parallel()
for _, source := range []string{"agent provider", "global provider", "agent provider overrides global"} {
t.Run(source, func(t *testing.T) {
t.Parallel()
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/custom/v1/systemone", r.URL.Path)
assert.Equal(t, "Bearer custom-evaluator-secret", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var payload json.RawMessage
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) {
return
}
assert.JSONEq(t, `{
"model":"jev",
"state":{"tool_name":"shell","tool_input":{"cmd":"git status"}},
"questions":{"evaluation":{
"type":"choice",
"instructions":"Classify the proposed tool call.",
"criteria":{"safe":"Safe to proceed","unsafe":"Do not proceed"}
}}
}`, string(payload))
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{"model":"jev-resolved","answers":{"evaluation":{"type":"choice","choice":"safe","probabilities":{"safe":0.98,"unsafe":0.02}}},"usage":{"input_tokens":12,"output_tokens":3}}`)
assert.NoError(t, err)
}))
t.Cleanup(server.Close)
provider := latest.ProviderConfig{
Provider: "typesafe", BaseURL: server.URL + "/custom", TokenKey: "CUSTOM_EVALUATOR_KEY",
}
data := evaluatorTeamYAML
runConfig := &config.RuntimeConfig{
EnvProviderForTests: environment.NewMapEnvProvider(map[string]string{
"OPENAI_API_KEY": "fake-chat-key",
"CUSTOM_EVALUATOR_KEY": "custom-evaluator-secret",
"TYPESAFE_API_KEY": "wrong-default-key",
}),
}
if source == "global provider" {
runConfig.Providers = map[string]latest.ProviderConfig{"risk_api": provider}
} else {
data = fmt.Sprintf("providers:\n risk_api:\n provider: typesafe\n base_url: %s\n token_key: CUSTOM_EVALUATOR_KEY\n", provider.BaseURL) + data
if source == "agent provider overrides global" {
runConfig.Providers = map[string]latest.ProviderConfig{"risk_api": {
Provider: "typesafe", BaseURL: server.URL + "/wrong", TokenKey: "WRONG_GLOBAL_KEY",
}}
}
}
loaded, err := LoadWithConfig(t.Context(), config.NewBytesSource("evaluators.yaml", []byte(data)), runConfig,
withTestProviderRegistry(WithStrict(config.FeatureHooks, config.FeatureEvaluators))...)
require.NoError(t, err)
assert.Zero(t, requests.Load(), "loading evaluator definitions must not call the API")
assert.Equal(t, provider, loaded.Providers["risk_api"])
client, ok := loaded.Team.Evaluator("safety")
require.True(t, ok)
require.NotNil(t, client)
unused, ok := loaded.Team.Evaluator("unused")
require.True(t, ok, "all definitions must be built, including unreferenced evaluators")
require.NotNil(t, unused)
assert.NotSame(t, client, unused)
missing, ok := loaded.Team.Evaluator("missing")
assert.False(t, ok)
assert.Nil(t, missing)
root, err := loaded.Team.Agent("root")
require.NoError(t, err)
assert.True(t, root.HasEvaluatorScope())
scoped, ok := root.Evaluator("safety")
require.True(t, ok)
assert.Same(t, client, scoped)
require.NotNil(t, root.Hooks())
require.Len(t, root.Hooks().ToolGuard, 1)
matcher := root.Hooks().ToolGuard[0]
assert.Equal(t, "shell", matcher.Matcher)
require.Len(t, matcher.Hooks, 1)
hook := matcher.Hooks[0]
assert.Equal(t, "evaluator", hook.Type)
assert.Equal(t, "safety", hook.Evaluator)
require.NotNil(t, hook.EvaluatorPolicy)
assert.Equal(t, map[string]string{"safe": "allow", "unsafe": "deny"}, hook.EvaluatorPolicy.Decisions)
assert.InDelta(t, 0.9, hook.EvaluatorPolicy.MinProbability, 1e-9)
assert.Equal(t, "ask", hook.EvaluatorPolicy.Fallback)
for range 2 {
reused, ok := loaded.Team.Evaluator(hook.Evaluator)
require.True(t, ok)
assert.Same(t, client, reused)
result, err := reused.Evaluate(t.Context(), map[string]any{
"tool_name": "shell", "tool_input": map[string]any{"cmd": "git status"},
})
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "choice", result.Type)
assert.Equal(t, "safe", result.Choice)
assert.Equal(t, "jev-resolved", result.Model)
assert.Equal(t, map[string]float64{"safe": 0.98, "unsafe": 0.02}, result.Probabilities)
assert.EqualValues(t, 12, result.Usage.InputTokens)
assert.EqualValues(t, 3, result.Usage.OutputTokens)
}
assert.EqualValues(t, 2, requests.Load())
})
}
}
type evaluatorCredentialProbe struct {
calls atomic.Int64
}
func (p *evaluatorCredentialProbe) Get(context.Context, string) (string, bool) {
p.calls.Add(1)
return "", false
}
func TestLoadEvaluatorsStrictBeforeCredentials(t *testing.T) {
t.Parallel()
tests := []struct {
name string
features []config.Feature
missing string
}{
{name: "hooks alone do not enable evaluators", features: []config.Feature{config.FeatureHooks}, missing: `feature "evaluators" at evaluators.safety`},
{name: "evaluators alone do not enable hooks", features: []config.Feature{config.FeatureEvaluators}, missing: `feature "hooks" at agents.root.hooks`},
{name: "both capabilities reach credential preflight", features: []config.Feature{config.FeatureHooks, config.FeatureEvaluators}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := &evaluatorCredentialProbe{}
runConfig := &config.RuntimeConfig{
Config: config.Config{Providers: map[string]latest.ProviderConfig{
"risk_api": {Provider: "typesafe", TokenKey: "CUSTOM_EVALUATOR_KEY"},
}},
EnvProviderForTests: env,
}
_, err := Load(t.Context(), config.NewBytesSource("evaluators.yaml", []byte(evaluatorTeamYAML)), runConfig,
withTestProviderRegistry(WithStrict(tt.features...))...)
require.Error(t, err)
if tt.missing != "" {
var unsupported *config.UnsupportedError
require.ErrorAs(t, err, &unsupported)
assert.Contains(t, err.Error(), tt.missing)
assert.Zero(t, env.calls.Load(), "unsupported capabilities must fail before any credential access")
} else {
assert.Contains(t, err.Error(), "CUSTOM_EVALUATOR_KEY")
assert.Contains(t, err.Error(), "OPENAI_API_KEY")
assert.NotContains(t, err.Error(), "UNUSED_EVALUATOR_KEY")
assert.Positive(t, env.calls.Load())
}
})
}
}
func TestLoadBindsEmptyEvaluatorScope(t *testing.T) {
t.Parallel()
const agentYAML = `agents:
root:
model: openai/gpt-4o
instruction: test
`
parentYAML := agentYAML + " sub_agents: [child:example/helper]\n"
tm, err := Load(t.Context(), config.NewBytesSource("parent.yaml", []byte(parentYAML)), &config.RuntimeConfig{
EnvProviderForTests: environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "fake-chat-key"}),
}, withTestProviderRegistry(WithSourceResolver(func(ref string, _ environment.Provider) (config.Source, error) {
assert.Equal(t, "example/helper", ref)
return config.NewBytesSource("child.yaml", []byte(agentYAML)), nil
}))...)
require.NoError(t, err)
for _, name := range []string{"root", "child"} {
a, err := tm.Agent(name)
require.NoError(t, err)
assert.True(t, a.HasEvaluatorScope(), "loaded agents must not inherit team-level evaluators")
client, ok := a.Evaluator("safety")
assert.False(t, ok)
assert.Nil(t, client)
}
}
func TestLoadEvaluatorsInheritedHooksPreflight(t *testing.T) {
t.Parallel()
const manifest = `
evaluators:
risk:
provider: typesafe
model: jev-latest
type: boolean
instructions: Assess risk.
agents:
root:
model: openai/gpt-4o
`
for _, tc := range []struct{ name, reference, want string }{
{"missing credentials", "risk", "TYPESAFE_API_KEY"},
{"unknown inherited reference", "missing", "unknown evaluator"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rc := &config.RuntimeConfig{EnvProviderForTests: environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "test"})}
rc.GlobalHooks = &latest.HooksConfig{ToolGuard: latest.HookMatcherConfigs{{Hooks: latest.HookDefinitions{{
Type: "evaluator", Evaluator: tc.reference, EvaluatorPolicy: &latest.EvaluatorPolicy{
Decisions: map[string]string{"true": "ask"}, MinProbability: 0.9, Fallback: "ask",
},
}}}}}
_, err := Load(t.Context(), config.NewBytesSource("test.yaml", []byte(manifest)), rc, withTestProviderRegistry()...)
require.ErrorContains(t, err, tc.want)
})
}
}
func TestLoadIgnoresUnusedGlobalProviderAuth(t *testing.T) {
t.Parallel()
rc := &config.RuntimeConfig{EnvProviderForTests: environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "test"})}
rc.Providers = map[string]latest.ProviderConfig{
"unused": {Provider: "anthropic", Auth: &latest.AuthConfig{Type: "typo"}},
}
loaded, err := Load(t.Context(), config.NewBytesSource("agent.yaml", []byte(`
agents:
root:
model: openai/gpt-4o
`)), rc, withTestProviderRegistry()...)
require.NoError(t, err)
require.NotNil(t, loaded)
}
func TestLoadValidatesInheritedHooks(t *testing.T) {
t.Parallel()
rc := &config.RuntimeConfig{EnvProviderForTests: environment.NewNoEnvProvider()}
rc.GlobalHooks = &latest.HooksConfig{ToolGuard: latest.HookMatcherConfigs{{Matcher: "[", Hooks: latest.HookDefinitions{{
Type: "command", Command: "echo test",
}}}}}
_, err := Load(t.Context(), config.NewBytesSource("agent.yaml", []byte(`
agents:
root:
model: openai/gpt-4o
`)), rc, withTestProviderRegistry()...)
require.ErrorContains(t, err, "invalid matcher")
}
+27 -4
View File
@@ -22,6 +22,8 @@ import (
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/evaluator"
evaluatorprovider "github.com/docker/docker-agent/pkg/evaluator/provider"
"github.com/docker/docker-agent/pkg/gateway"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/model/provider/dmr/dmrmodels"
@@ -291,6 +293,15 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
// `docker agent setup` resolve in every run, including inline
// `--model myprovider/mymodel` overrides. Agent-file definitions win.
config.MergeGlobalProviders(cfg, runConfig.Providers)
config.MergeAgentHooks(cfg, config.MergeHooks(runConfig.GlobalHooks, runConfig.CLIHooks()))
if err := cfg.ValidateEvaluators(); err != nil {
return nil, err
}
for _, a := range cfg.Agents {
if err := a.Hooks.Validate(); err != nil {
return nil, fmt.Errorf("agents.%s: %w", a.Name, err)
}
}
// Resolve model aliases (e.g., "claude-sonnet-4-5" -> "claude-sonnet-4-5-20250929")
// This ensures the API uses the pinned model version. The original name is preserved
@@ -369,6 +380,19 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
}
}
evaluators := make(map[string]evaluator.Evaluator, len(cfg.Evaluators))
for name, def := range cfg.Evaluators {
resolved, err := def.Resolve(cfg.Providers)
if err != nil {
return nil, fmt.Errorf("evaluator %q: %w", name, err)
}
client, err := evaluatorprovider.New(ctx, resolved, env)
if err != nil {
return nil, fmt.Errorf("evaluator %q: %w", name, err)
}
evaluators[name] = client
}
// Make model definitions available to toolset creators (e.g., RAG reranking)
runConfig.Models = cfg.Models
runConfig.Providers = cfg.Providers
@@ -385,9 +409,6 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
expander := loadOpts.newExpander(env)
globalHooks := runConfig.GlobalHooks
cliHooks := runConfig.CLIHooks()
for _, agentConfig := range cfg.Agents {
// Merge CLI prompt files with agent config prompt files, deduplicating
promptFiles := slices.Concat(agentConfig.AddPromptFiles, loadOpts.promptFiles)
@@ -422,7 +443,8 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
agent.WithNumHistoryItems(agentConfig.NumHistoryItems),
agent.WithSessionCompaction(agentConfig.SessionCompactionEnabled()),
agent.WithCommands(expander.ExpandCommands(ctx, agentConfig.Commands)),
agent.WithHooks(config.MergeHooks(config.MergeHooks(agentConfig.Hooks, globalHooks), cliHooks)),
agent.WithHooks(agentConfig.Hooks),
agent.WithEvaluators(evaluators),
agent.WithStructuredOutput(agentConfig.StructuredOutput),
}
@@ -622,6 +644,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
return &LoadResult{
Team: team.New(
team.WithAgents(agents...),
team.WithEvaluators(evaluators),
team.WithPermissions(permChecker),
team.WithAgentConfigs(agentConfigs),
team.WithRuntimeSafety(runtimeSafety),
+3
View File
@@ -170,6 +170,9 @@ func gatherExampleEnvVars(t *testing.T, examples []string) map[string]bool {
for _, env := range config.GatherEnvVarsForModels(ctx, cfg, environment.NewOsEnvProvider()) {
envs[env] = true
}
for _, env := range config.GatherEnvVarsForEvaluators(cfg) {
envs[env] = true
}
toolEnvs, _ := config.GatherEnvVarsForTools(ctx, cfg)
for _, env := range toolEnvs {
envs[env] = true