What it is
Tools are callable functions the LLM can request during an agent run. They are the primary way an agent interacts with the outside world: reading/writing files, executing commands, delegating to sub-agents, or asking a human for confirmation.
Phero tools are just function tools from llm, registered on an agent.
Built-in tool packages
Phero includes ready-to-use tools under tool/. Each tool is constructed with a New…
function and exposes a .Tool() method that returns the *llm.Tool to pass to AddTool.
Exact behavior can be customized with llm.Tool.Use(...) middleware.
- Agent tool (
tool/agent): create and run a sub-agent at runtime with a given name, role, and instructions - Filesystem tools (
tool/file): six separate tools —read,write,edit,multi_edit,glob,grep - Bash tool (
tool/bash): run bash commands with optional guardrails (blocklist, allowlist, timeout, safe mode) and background execution - Human tool (
tool/human): present structured questions and collect user answers mid-run - Skill dispatcher (
tool/skill): load a namedSKILL.mdinto the current conversation - Web fetch (
tool/web): fetch a page as text, with a size cap, a timeout and private networks refused - Task list (
tool/todo): let the agent write, track and tick off the steps of a long task - Permissions (
tool/permission): allow, deny or ask a human before any tool call, with rules such asbash(go test *)
Tip: for anything with side effects (writes or shell commands), add an approval middleware.
Agent tool (tool/agent)
The agent tool lets an orchestrator delegate an open-ended task to a purpose-built sub-agent at runtime.
The LLM provides the sub-agent's name, description (system prompt), and input (task instructions).
The tool creates an agent, runs it, and returns the text result.
import agenttool "github.com/henomis/phero/v2/tool/agent"
// Pass the same LLM client used by the orchestrator.
agentTool, err := agenttool.New(llmClient)
if err != nil {
panic(err)
}
if err := a.AddTool(agentTool.Tool()); err != nil {
panic(err)
}
Optionally pass tools that every created sub-agent will have access to:
readTool, _ := file.NewReadTool()
writeTool, _ := file.NewWriteTool()
agentTool, err := agenttool.New(llmClient, readTool.Tool(), writeTool.Tool())
if err != nil {
panic(err)
}
The JSON input schema expected from the LLM:
name: short name for the delegated agentdescription: system prompt / role descriptioninput: the task or question the agent should solve
Errors: ErrLLMRequired, ErrNilInput, ErrNameRequired, ErrDescriptionRequired, ErrInputRequired.
Filesystem tools (tool/file)
Each filesystem operation is a separate constructor returning its own typed struct.
All constructors accept shared Option values.
import "github.com/henomis/phero/v2/tool/file"
readTool, err := file.NewReadTool(file.WithWorkingDirectory("/workspace"))
writeTool, err := file.NewWriteTool(file.WithWorkingDirectory("/workspace"))
editTool, err := file.NewEditTool(file.WithWorkingDirectory("/workspace"))
globTool, err := file.NewGlobTool(file.WithWorkingDirectory("/workspace"))
grepTool, err := file.NewGrepTool(file.WithWorkingDirectory("/workspace"))
// Each exposes .Tool() to get the *llm.Tool:
if err := a.AddTool(readTool.Tool()); err != nil {
panic(err)
}
read returns plain cat -n text (a 6-wide line number, a tab, then the line), not JSON.
Tabs and quotes reach the model as they are in the file, so it can copy them into an edit's
old_string. file.ReadOutput is deprecated since v2.3.0.
Migrating from v2.2: code that calls the read tool's handler directly and decoded its JSON
(file.ReadOutput) now gets a string: the numbered lines, followed by a note when the read was cut
or paged.
Available options:
WithWorkingDirectory(dir): confine all operations to a directory; paths are validated against it to prevent traversal, after their symlinks are resolved.file.ResolvePath(dir, path)runs the same resolution and check outside a callWithReadOnlyRoots(dirs...): let read, grep and glob also reach the files underdirs, outside the working directory, such as the user's skill directories, whoseSKILL.mdpoints to scripts and references. Symlinks are resolved first, so one that leads out of every allowed directory is still refused. A relative dir is resolved against the working directory. Write, edit and multi_edit ignore it and stay in the working directoryWithMaxFileSize(bytes): limit the file size the edit tools accept, and the read tool for a whole-file read (defaultfile.DefaultMaxFileSize, 10 MiB; 0 = unlimited). A read withoffsetorlimitpages a file of any size.WithMaxOutputChars(n): cap the text one read, grep or glob returns (defaultfile.DefaultMaxOutputChars, 30,000; 0 = no cap). A cut read ends with[output cut at … characters: read on with offset=N], andlimitis capped at 2,000 lines. Grep and glob drop the entries that do not fit and setTruncated.WithReadTracker(tracker): share afile.NewReadTracker()between the tools so that write, edit and multi_edit refuse to change an existing file the model has not read (ErrFileNotRead), or that changed on disk since it read it (*ChangedError). New files need no read, and the tools' own changes count as seen.tracker.Reset()forgets every read, for a new session that reuses the same toolsWithNoOverwrite(): configure write to returnErrFileExistswhen the target already existsWithOnWrite(hook): write, edit and multi_edit callhook(ctx, change)after each change they write, with afile.Changeholding the resolved path, the content before and after, whether the file existed, and its permissions. It is not called for a failed call. Use it to record changes for an undoWithMaxResults(n): how many pathsglob, and entriesgrep, return at most (defaultfile.DefaultMaxResults, 1000; 0 removes the cap). A cut result setsTruncated. Grep'shead_limitcan only lower itWithNoIgnore(): search everything, including what.gitignoreignores andnode_modules
multi_edit (file.NewMultiEditTool) applies a list of exact replacements to one file, in
order, each seeing the result of the ones before it. Either every edit applies or none does, so a failed edit
never leaves the file half changed. edit, multi_edit and write are
exclusive tools: when the model calls several at once, they run one at a time, in call order.
Each of the three tools has a Preview(input) method that returns the *file.Change a
call would make, without writing it. It runs the call's own code and checks (uniqueness,
replace_all, the size limit, the read tracker, the working directory), so it fails exactly as the
call would. A permission dialog can show the diff before the call runs. The tools decode their arguments
with encoding/json, so the dialog can decode Request.Arguments into
file.EditInput the same way. file.WriteFileAtomic writes a file as the tools do
(a temporary file, then a rename), for example to restore a change's Before.
journal := []file.Change{}
opts := []file.Option{
file.WithWorkingDirectory("/workspace"),
file.WithOnWrite(func(_ context.Context, c file.Change) { journal = append(journal, c) }),
}
editTool, _ := file.NewEditTool(opts...)
// In the permission Asker, before the call runs:
var in file.EditInput
if err := json.Unmarshal([]byte(req.Arguments), &in); err == nil {
if change, err := editTool.Preview(&in); err == nil {
showDiff(change.Path, change.Before, change.After)
}
}
// Undo the last change:
last := journal[len(journal)-1]
if last.Existed {
err = file.WriteFileAtomic(last.Path, last.Before, last.Perm)
} else {
err = os.Remove(last.Path)
}
glob and grep are built for real repositories. They skip .git,
.hg, .svn and node_modules, and what the .gitignore files
ignore, from the repository root down (negation, directory-only and anchored patterns included).
grep skips binary files and clips very long lines, such as minified code. With a working
directory, a symlinked file whose target is outside it is skipped.
Using middleware to require user confirmation before a write:
writeTool, err := file.NewWriteTool(file.WithWorkingDirectory("/workspace"))
if err != nil {
panic(err)
}
writeTool.Tool().Use(func(_ *llm.Tool, next llm.ToolHandler) llm.ToolHandler {
return func(ctx context.Context, arguments string) (any, error) {
var input file.WriteInput
if err := json.Unmarshal([]byte(arguments), &input); err != nil {
return nil, err
}
fmt.Printf("Write to %s? (y/N): ", input.FilePath)
var answer string
fmt.Scanln(&answer)
if !strings.EqualFold(answer, "y") {
return nil, fmt.Errorf("write denied by user")
}
return next(ctx, arguments)
}
})
if err := a.AddTool(writeTool.Tool()); err != nil {
panic(err)
}
Bash tool (tool/bash)
The bash tool runs shell commands with configurable guardrails and supports background execution.
bash.New returns a *Tool that exposes three *llm.Tool values:
Tool(): the mainbashtool — execute a command, optionally in the backgroundOutputTool():bash_output— retrieve incremental output from a running background shell. Once a finished shell's output has been read in full, its ID is forgotten.KillTool():kill_shell— terminate a background shell by ID
import (
"time"
"github.com/henomis/phero/v2/tool/bash"
)
bashTool, err := bash.New(
bash.WithSafeMode(), // built-in dangerous-command blocklist + 30 s timeout
bash.WithWorkingDirectory("/tmp/sandbox"),
)
if err != nil {
panic(err)
}
defer bashTool.Close() // kill the background shells when the app ends
// Register all three tools so the agent can manage background processes:
if err := a.AddTool(bashTool.Tool()); err != nil { panic(err) }
if err := a.AddTool(bashTool.OutputTool()); err != nil { panic(err) }
if err := a.AddTool(bashTool.KillTool()); err != nil { panic(err) }
Guardrail options:
WithSafeMode(): enables a built-in blocklist (rm -rf /,dd if=, fork bombs, …) and a 30 s timeoutWithTimeout(d): fixed maximum execution time per commandWithDefaultTimeout(d): timeout used when the LLM does not supply one in the request (default: 120 s)WithMaxTimeout(d): cap on the LLM-supplied per-request timeout value (default: 600 s)WithMaxOutputChars(n): output truncation threshold (defaultbash.DefaultMaxOutputChars, 30,000 bytes). Longer output keeps its start and its end, where errors usually are, around a[... N bytes truncated ...]marker, without splitting a UTF-8 characterWithBlocklist(patterns...): any command containing a pattern (case-insensitive) returnsErrCommandBlockedWithAllowlist(patterns...): when set, the command must match at least one pattern orErrCommandNotAllowedis returnedWithWorkingDirectory(dir): directory in which commands runWithRunner(r): how commands are launched;bash.LocalRunner(the default) runs them on the host,bash.NewBubblewrapRunnerin a sandbox (below)WithEnvAllowlist(names...): pass only these environment variables to commands instead of the whole process environment;bash.DefaultEnvAllowlist()keeps what common tools need and no secretsWithEnv(entries...): addKEY=VALUEentries to every command's environment. They are added to the allowlisted variables or, without an allowlist, to the runner's default: the whole process environment forLocalRunner,DefaultEnvAllowlist()in the sandbox. A customRunnergets them inRunOptions.ExtraEnvand must apply themWithPersistentShell(): carry the working directory and exported variables from one command to the next, as in an interactive shell (aftercd sub, the next command runs insub). Each command still runs in its own process; one that exits, times out or runs in the background changes nothing. Without it, every command starts fresh in the working directory, and the tool's description says so to the model.WithOutputHandler(fn): get a foreground command's output as it is written (below)
Streaming output
By default nothing is visible until a command exits, so a long go test looks like a hang.
WithOutputHandler calls a function with each piece of output as the command writes it (stdout and
stderr interleaved, as in the result). The model still gets the whole output when the command ends. The
function gets the tool call's context, so llm.ToolCallFromContext tells which call a chunk
belongs to. Background commands are not streamed: the model reads them with bash_output.
bashTool, err := bash.New(
bash.WithOutputHandler(func(ctx context.Context, chunk []byte) {
os.Stderr.Write(chunk) // show progress while the command runs
}),
)
Sandbox (Linux)
The blocklist and allowlist match substrings of the command and are easy to get around: they are not a
security boundary. On Linux, NewBubblewrapRunner runs every command in a
bubblewrap sandbox: of the host file system only the
system directories (bash.SystemPaths(): /usr, /etc, /lib*, …)
are mounted, read-only, plus the workspace. /tmp, /run and $HOME are
private and empty, so host Unix sockets (docker.sock, the D-Bus buses, agent sockets) and files
such as ~/.ssh are out of reach: connect() on a socket ignores read-only mounts, so
the sandbox does not mount them at all. A workspace or extra path that is / or contains
$HOME is refused (bash.ErrPathTooBroad), and commands run in a new session, detached
from the caller's terminal. There is no network by default; WithNetwork(true) shares the host's
network namespace, so commands also reach every service on localhost (a Docker TCP socket, databases, a local
LLM server) and abstract Unix sockets such as X11's. The environment is limited to
DefaultEnvAllowlist(), and the sandbox has its own PID namespace, so a timeout kills everything a
command started.
runner, err := bash.NewBubblewrapRunner(repoDir,
bash.WithWritablePaths(goBuildCache, goModCache), // tools that write outside the workspace
bash.WithReadOnlyPaths(toolchainDir), // tools installed outside the system directories
bash.WithReadOnlyPaths(repoDir+"/.git"), // inside the workspace: the most specific mount wins
// bash.WithNetwork(true), // only if commands need it: it shares the host's network
)
if err != nil { panic(err) } // bash.ErrBubblewrapNotFound when bwrap is not installed
bashTool, err := bash.New(bash.WithRunner(runner), bash.WithWorkingDirectory(repoDir))
Any type implementing bash.Runner can replace it, for example one that runs commands in a
container: it only builds the *exec.Cmd, and the tool keeps timeouts, process groups, background
shells and output handling.
Background execution fields in Input:
RunInBackground bool: start the command in the background; the tool returns abash_idimmediatelyTimeout int: optional per-request timeout in milliseconds. For a foreground command it is capped byWithMaxTimeout; for a background command, byWithBackgroundTimeoutwhen set
Background shells
A background command has its own lifetime, so a dev server or a watcher keeps running. The foreground limits
(WithTimeout, WithDefaultTimeout, WithMaxTimeout, and the safe-mode
timeout) do not apply to it.
WithBackgroundTimeout(d): kill a background command afterd, and cap the timeout the model asks for. 0 (the default) means no limitWithMaxBackgroundOutput(n): how many bytes of output each shell keeps (defaultbash.DefaultMaxBackgroundOutput, 1 MiB; 0 = no cap). Older output is dropped; when unread output was dropped,bash_outputstarts with[... N bytes dropped ...]and setsDroppedClose(): kill every background shell and wait briefly for them to exit. Call it withdefer. Foreground commands still run afterwards; a new background command returnsbash.ErrClosed
A command killed at its timeout is not an error: the result holds what it printed until then, followed by
"command timed out after …", with TimedOut set, so the model sees how far it got. A cancelled
caller context still returns the context's error.
On Unix, each command runs in its own process group. A timeout, a cancelled context or
kill_shell kills the whole group, so processes the command started (a dev server, a
test runner's workers) do not outlive it.
Running a coding agent safely
A coding agent runs commands and changes files that a model chose. Each guard below covers a different risk. examples/coding-agent puts them together.
- Bash permission rules are advisory. They match the command text, so a deny rule misses
the same program written another way (
/bin/rm,sh -c, a Makefile). Commands that hide what they run (escaped names, substitutions, assignments before a command) are asked, not allowed. Use rules to stop mistakes, not as a boundary. - The sandbox is the boundary.
LocalRunner, the default, has no isolation.NewBubblewrapRunneris Linux-only.WithNetwork(true)shares the host network, localhost services included. - Keep secrets out of commands. The sandbox passes only
DefaultEnvAllowlist()by default. WithLocalRunner, setbash.WithEnvAllowlist(bash.DefaultEnvAllowlist()...): without it, commands get the agent's whole environment, API keys included. - The file tools run outside the sandbox. Confine them with
file.WithWorkingDirectory; without it they reach any path the process can. - Bound every turn. A coding agent needs more than the default 25 iterations: use
SetMaxIterations(0)and acontext.WithTimeoutper turn.
Recommended setup: bubblewrap, an env allowlist, the file tools confined to the workspace, a permission
policy with an Asker for everything not allowed, and a deadline per turn.
Human tool (tool/human)
The human tool pauses the agent to collect structured input from the user.
You provide the interactor — the function that presents questions and reads answers — via
WithInteractor. This keeps the tool backend-agnostic (terminal, web UI, etc.).
import "github.com/henomis/phero/v2/tool/human"
humanTool, err := human.New(
human.WithInteractor(consoleInteractor),
)
if err != nil {
panic(err)
}
if err := a.AddTool(humanTool.Tool()); err != nil {
panic(err)
}
The Interactor signature:
type Interactor func(ctx context.Context, input *human.Input) (map[string]human.Answer, error)
The LLM supplies an Input with 1–4 questions. Each Question has:
Header string: short label (max 12 characters); used as the answer map keyQuestion string: text shown to the user (must end with?)MultiSelect bool: whether multiple options can be selectedOptions []Choice: 2–4 options, each with aLabel(1–5 words) andDescription
A minimal terminal interactor:
func consoleInteractor(_ context.Context, in *human.Input) (map[string]human.Answer, error) {
answers := make(map[string]human.Answer)
for _, q := range in.Questions {
fmt.Printf("[%s] %s\n", q.Header, q.Question)
for i, opt := range q.Options {
fmt.Printf(" %d) %s — %s\n", i+1, opt.Label, opt.Description)
}
fmt.Print("Your choice: ")
var line string
fmt.Scanln(&line)
answers[q.Header] = human.Answer{Selections: []string{strings.TrimSpace(line)}}
}
return answers, nil
}
See examples/human-in-the-loop for a complete working example with a multi-step approval flow.
Skill dispatcher (tool/skill)
Use the skill dispatcher to expose a single skill tool that loads SKILL.md instructions
and a base path into the current conversation, keeping the agent toolset compact.
import skilltool "github.com/henomis/phero/v2/tool/skill"
dispatcher, err := skilltool.New("./skills")
if err != nil {
panic(err)
}
if err := a.AddTool(dispatcher.Tool()); err != nil {
panic(err)
}
The dispatcher scans skillsRootPath for subdirectories containing SKILL.md files,
builds a catalog, and embeds it in the tool description so the model can select the right skill by name at runtime.
A skill that cannot be used (it does not parse, has no name or description, or its name is taken) is left out,
and dispatcher.Skipped() says why; the others still load.
Available options:
WithRoots(roots...): read skills from several roots (user and project); earlier roots win, on a directory name or a skill name, so pass the user's firstWithOnLoad(fn): called each time the model loads a skill, for example to pre-approve itsallowed-toolsfor the run (policy.AllowForRun, filtered bypermission.SkillGrants)WithParser(parser): inject a customParserimplementation (useful for testing)
The model passes arguments for a skill in the arguments field: they replace $ARGUMENTS
in the skill's body, or are appended after it. The result lists the skill's allowed tools.
Web fetch (tool/web)
The web_fetch tool fetches a page and returns it as text: HTML becomes readable text with headings,
lists and links (as text [url]), without script or style; other text and JSON come back as they
are; binary content is refused. Responses are capped (WithMaxBytes, WithMaxOutputChars,
WithTimeout) and the result says when it was truncated.
fetcher := web.New(web.WithAllowedHosts("go.dev", "*.go.dev", "github.com"))
fetchTool, err := fetcher.Tool()
if err != nil { panic(err) }
if err := a.AddTool(fetchTool); err != nil { panic(err) }
The URL comes from the model, so the tool refuses addresses on loopback, private, link-local and
carrier-grade NAT networks and 0.0.0.0/8, including cloud metadata endpoints, and IPv6 addresses
that carry such an IPv4 address (NAT64, 6to4, Teredo). The check runs on the address it connects to, so a redirect or a DNS answer
cannot get around it. WithPrivateNetworks(true) allows them, for example to read a local dev server.
WithAllowedHosts limits the hosts, redirects included; only http and https
URLs are fetched.
Task list (tool/todo)
Over many turns an agent loses track of a long task. The todo_write tool lets it write the steps
down, mark the one it is on and tick them off. Each call replaces the whole list; at most one item is
in_progress. The tool answers with the list as a checklist.
list := todo.New(todo.WithOnChange(func(items []todo.Item) {
fmt.Println(todo.Render(items)) // or update a UI
}))
todoTool, err := list.Tool()
if err != nil { panic(err) }
if err := a.AddTool(todoTool); err != nil { panic(err) }
list.Items() returns the current items, for example to show them after the run. To resume a
session, save the items (they marshal to JSON) and start the next list with todo.WithItems:
var saved []todo.Item
if data, err := os.ReadFile(".phero/todo.json"); err == nil {
_ = json.Unmarshal(data, &saved)
}
list := todo.New(
todo.WithItems(saved), // must be valid items, as Items() returns
todo.WithOnChange(func(items []todo.Item) {
data, _ := json.Marshal(items)
_ = os.WriteFile(".phero/todo.json", data, 0o600)
}),
)
Permissions (tool/permission)
A permission.Policy decides, for each tool call, whether it runs, is denied, or is asked of a human
first. Rules name a tool and, optionally, a pattern on the call's subject (the command for bash, the file path for
read, write and edit, the search path for glob and grep); * matches any run of characters.
policy, err := permission.New(
permission.WithAllow("read", "grep", "glob", "bash(go test *)", "bash(go build *)", "edit(/repo/*)"),
permission.WithAsk("edit(/repo/go.mod)"),
permission.WithDeny("bash(git push *)", "edit(/repo/secrets/*)"), // bash rules guard against mistakes
permission.WithAsker(func(ctx context.Context, req permission.Request) (permission.Answer, error) {
fmt.Printf("Allow %s(%s)? [y/a/N] ", req.Tool, req.Subject)
// read the answer: permission.AllowOnce, permission.AllowAlways or permission.Reject
return permission.AllowOnce, nil
}),
)
if err != nil { panic(err) }
policy.Apply(readTool.Tool(), bashTool.Tool(), editTool.Tool())
- A call is denied when a deny rule matches, else asked when an ask rule matches, else allowed when an allow
rule matches, else given the default (
Ask; change it withWithDefault). - A bash command line is parsed as the shell would (
mvdan.cc/sh), and every simple command in it must be allowed: those joined by&&,||,;,|and&, and those inside subshells,if/forbodies, functions and substitutions.go test ./... && rm -rf /does not passbash(go test *); a quoted operator does not split (echo 'a && b'is one command), and commands are matched single-spaced. An assignment before a command is part of it:CGO_ENABLED=0 go testdoes not passbash(go test *), because a variable can change what an allowed program runs (GOFLAGS=-toolexec=...,LD_PRELOAD); a rule that names it does, asbash(CGO_ENABLED=0 go test *). A line is asked (still denied when a deny rule matches one of its commands) when it does not parse; holds the text of a command or process substitution anywhere, even quoted (bash runs$(...)found in an array subscript or${x@P}); evaluates arithmetic or indirection ($((...)),((...)),[[ ... ]],${a[i]},${!a}, …); only assigns or declares variables (PATH=.,export); names its command with something other than a plain word ($CMD, a function it defines); or redirects output to a file (cat a >> ~/.bashrcdoes not passbash(cat *));2>&1,>/dev/null, input redirections and heredocs write no file. - File paths are matched as the file tools open them: cleaned, made absolute against the working directory
(
WithWorkingDir, the process's by default; set it to the file tools' working directory), with a leading~read as the home directory, and resolved through symlinks. Path patterns in rules are resolved the same way, so/repo/src/../secrets/kand a symlink intosecretsboth meetread(/repo/secrets/*). Deny and ask rules also match the path as written, soread(/repo/*/.env)still covers a symlinked package. - Arguments that give the subject twice (
{"command":"go test","COMMAND":"rm -rf ~"}) are denied: the tool's handler decodes keys case-insensitively and keeps the last one, so the rules could otherwise see a different value from the one that runs. - Questions come one at a time, even when the agent runs tool calls in parallel. The policy is not locked
while a question is open: the
Asker, or the UI it waits for, can callAlways,ForgetorDecide, and calls that need no question run meanwhile.AllowAlwaysallows the same subject from then on;policy.Always()lists those answers andpolicy.Forget(tool, subject)drops one. Without anAsker, calls that need asking are denied. - A denied call returns a
*permission.DeniedError(matchespermission.ErrDenied), which the model sees as a failed tool call. - The
RequestanAskergets carries the model's tool call ID (CallID), what the call touches for a person to read (Subject: the whole command line for bash, the resolved path for a file tool), the subject argument as written (Raw: for bash, the whole command line with its pipes and&&), and the subjects anAllowAlwaysanswer allows from then on (Subjects), so a question can show both exactly.Subjectslists each subject once and leaves out those an earlier answer already allows, and those an ask rule matches, which are never stored: the ask rule would ask again anyway.Reasonsays why it is asked:ReasonNoRule,ReasonAskRuleorReasonOpaque(a command line it cannot split). - Bash rules are advisory, not a security boundary. They match the command text, so a deny
rule misses the same program written another way:
bash(rm -rf *)does not matchrm -rf,rm -fr,/bin/rm -rfor\rm -rf, nor rm run from insidesh -c, a script or a Makefile. Use deny rules to stop mistakes, allow rules for commands safe to run unasked, and the bubblewrap sandbox (bash.NewBubblewrapRunner, above) where a restriction must hold. - A
globorgrepcall with no path searches the working directory, soglob(/repo/secrets*)does not cover it: deny the directory itself or keep secrets outside the working directory. WithSubject(tool, argument)sets the subject argument of other tools, such as an MCP tool's URL. A subject argument namedfile_pathorpathis matched as a file path.- The policy knows the built-in tools by name. Only a tool named
bashhas its command line parsed into commands; a shell tool under another name is matched on its whole argument, unparsed, so give it rules for whole command lines, or keep the namebash.
Run an example
These are good starting points to see tools in action (provider setup is described in each example README):
# Human-in-the-loop: multi-step approval flow
go run ./examples/human-in-the-loop
# Skills + built-in file/bash tools
cd ./examples/skills && go run .
# Multi-agent workflow (uses a custom restricted command tool)
go run ./examples/multi-agent-workflow