feat(lint): add HookBuiltinsDocumented cop and document missing builtins

Adds the Lint/HookBuiltinsDocumented cop which checks that every builtin
registered in pkg/hooks/builtins/ is listed both in agent-schema.json and
in docs/configuration/hooks/index.md.

Also adds 'http_post', 'limit_large_tool_results', 'safer_shell', and
'snapshot' to the schema description and the hooks reference table, which
were previously missing and would have triggered the new cop.
This commit is contained in:
David Gageot committed 2026-09-14 09:17:53 +02:00
1 parent c9561ed033
commit da08454d8f
5 files changed
+184 -1

No files matched your search

+1 -1
View File
@@ -1434,7 +1434,7 @@
},
"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), '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 \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).",
"enum": [
"command",
"builtin",
+1
View File
@@ -284,6 +284,7 @@ Built-ins are typically zero-config and faster than equivalent shell hooks becau
| `snapshot` | `session_start`, `turn_start`, `turn_end`, `pre_tool_use`, `post_tool_use`, `session_end` | _none_ | Records filesystem snapshots in a shadow git repo under the Docker Agent data directory. No-op outside git repos; respects the source repo's ignore rules and skips newly-added files larger than 2 MiB. |
| `redact_secrets` | `tool_input_transform`, `before_llm_call`, `tool_response_transform` | _none_ | Scrubs detected secrets (API keys, tokens, private keys, …) out of tool call arguments, outgoing chat content, and tool output. The same builtin handles all three events and dispatches on the event name. Auto-registered on all three events by `redact_secrets: true` on the agent — see [`examples/redact_secrets_hooks.yaml`](https://github.com/docker/docker-agent/blob/main/examples/redact_secrets_hooks.yaml) for the manual wiring. |
| `limit_large_tool_results` | `tool_response_transform`, `session_end` | _none_ | **Always-on safety hook** — automatically injected by the runtime, no configuration required. When a tool result from the `filesystem`, `shell`, `mcp`, or `a2a` categories exceeds 2,000 lines or 50 KiB, the full payload is written to a per-session temp file and replaced in the conversation with a notice plus a bounded excerpt (2,000 lines, up to 50 KiB): the tail for most tools, but the head for the built-in filesystem `read_file`, whose notice suggests a follow-up call with `line`/`limit` to continue reading. The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. |
| `http_post` | Any event | `[URL, body]` | Sends an HTTP POST request with the optional body in `args[1]` to the URL in `args[0]`. Missing or empty URLs are ignored; only HTTP(S) destinations are accepted, using an SSRF-safe transport. |
| `safer_shell` | `pre_tool_use` | _none_ | **Deprecated compatibility shim.** The runtime now classifies every shell command natively (`safe` / `destructive` / `unknown`) and gates it through the session's [safety mode](../permissions/index.md#safety-modes), so this builtin no longer emits verdicts. Pinned entries keep working as pure labellers that attach classification metadata (`safety_label`, `blast_radius`, `category`, `reason`) to the call. Filters by tool name internally (no-op for calls other than `shell` and `run_background_job`). |
| `unload` | `on_agent_switch` | _none_ | POSTs `{"model": "<id>"}` to each of the previous agent's DMR model endpoints (`/_unload` by default, overridable per-model via `unload_api`) to free the GPU/RAM the just-departing model was holding. Pure HTTP — reads the model snapshot the runtime ships on `on_agent_switch` and depends on no provider-specific runtime state. Non-DMR providers (OpenAI, Anthropic, …) are silently skipped, so cross-provider chains are safe. Errors are logged and swallowed; agent switching never blocks on a slow or unreachable engine (each call has a 10 s timeout). See [`examples/unload_on_switch.yaml`](https://github.com/docker/docker-agent/blob/main/examples/unload_on_switch.yaml). |
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"bufio"
"encoding/json"
"go/ast"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/dgageot/rubocop-go/cop"
)
// HookBuiltinsDocumented keeps the builtin registry, schema help, and hook
// reference synchronized. A builtin omitted from either documentation surface
// still works at runtime but is undiscoverable to users and schema-driven tools.
var HookBuiltinsDocumented = &cop.Func{
Meta: cop.Meta{
Name: "Lint/HookBuiltinsDocumented",
Description: "every hook builtin must be listed in the schema and hook reference",
Severity: cop.Error,
},
Scope: cop.OnlyFile("pkg/hooks/builtins/builtins.go"),
Run: func(p *cop.Pass) {
declared, err := declaredBuiltinWireNames(p)
if err != nil || len(declared) == 0 {
return
}
root := filepath.Clean(filepath.Join(filepath.Dir(p.Filename()), "..", "..", ".."))
schemaNames, err := hookBuiltinsInSchema(filepath.Join(root, "agent-schema.json"))
if err != nil {
p.Reportf(p.File.Name, "read agent-schema.json: %v", err)
return
}
docNames, err := hookBuiltinsInReference(filepath.Join(root, "docs", "configuration", "hooks", "index.md"))
if err != nil {
p.Reportf(p.File.Name, "read docs/configuration/hooks/index.md: %v", err)
return
}
var missingSchema, missingDocs []string
for _, wireName := range declared {
if !schemaNames[wireName] {
missingSchema = append(missingSchema, wireName)
}
if !docNames[wireName] {
missingDocs = append(missingDocs, wireName)
}
}
p.ReportMissing(p.File.Name, "agent-schema.json is missing hook builtin(s): %s", missingSchema)
p.ReportMissing(p.File.Name, "docs/configuration/hooks/index.md is missing hook builtin(s): %s", missingDocs)
},
}
func declaredBuiltinWireNames(p *cop.Pass) ([]string, error) {
declared, err := p.DirStringConsts(".", cop.ParseDirOptions{
SkipTests: true,
SkipFiles: []string{"builtins.go"},
}, ast.IsExported)
if err != nil {
return nil, err
}
names := make([]string, 0, len(declared))
for _, value := range declared {
names = append(names, value)
}
return names, nil
}
func hookBuiltinsInSchema(path string) (map[string]bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var schema struct {
Definitions map[string]struct {
Properties map[string]struct {
Description string `json:"description"`
} `json:"properties"`
} `json:"definitions"`
}
if err := json.Unmarshal(data, &schema); err != nil {
return nil, err
}
description := schema.Definitions["HookDefinition"].Properties["type"].Description
return singleQuotedNames(description), nil
}
func hookBuiltinsInReference(path string) (map[string]bool, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
names := map[string]bool{}
scanner := bufio.NewScanner(file)
inBuiltinsTable := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !inBuiltinsTable {
inBuiltinsTable = strings.HasPrefix(line, "| Builtin")
continue
}
if !strings.HasPrefix(line, "|") {
break
}
if !strings.HasPrefix(line, "| `") {
continue
}
name, _, ok := strings.Cut(strings.TrimPrefix(line, "| `"), "`")
if ok {
names[name] = true
}
}
return names, scanner.Err()
}
var singleQuotedName = regexp.MustCompile(`'([a-z][a-z0-9_]*)' \(`)
func singleQuotedNames(text string) map[string]bool {
names := map[string]bool{}
for _, match := range singleQuotedName.FindAllStringSubmatch(text, -1) {
names[match[1]] = true
}
return names
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHookBuiltinsInSchema(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "schema.json")
require.NoError(t, os.WriteFile(path, []byte(`{
"definitions": {
"HookDefinition": {
"properties": {
"type": {"description": "Types: 'command', 'builtin'. Builtins: 'add_date' (date), 'http_post' (POST). Args include 'full'."}
}
}
}
}`), 0o600))
names, err := hookBuiltinsInSchema(path)
require.NoError(t, err)
assert.True(t, names["add_date"])
assert.True(t, names["http_post"])
assert.False(t, names["snapshot"])
}
func TestHookBuiltinsInReference(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "index.md")
require.NoError(t, os.WriteFile(path, []byte(`| Builtin | Event |
| --- | --- |
| `+"`add_date`"+` | `+"`turn_start`"+` |
| Event | Description |
| --- | --- |
| `+"`snapshot`"+` | Other table |
`), 0o600))
names, err := hookBuiltinsInReference(path)
require.NoError(t, err)
assert.True(t, names["add_date"])
assert.False(t, names["snapshot"])
}
+1
View File
@@ -30,6 +30,7 @@ var cops = []cop.Cop{
RuntimeSessionScoped,
HookConfigSync,
HookBuiltinsRegistered,
HookBuiltinsDocumented,
SlogContextual,
ConstructorPurity,
ConstructorCommandExec,