Safety and Human Approval
Source:vignettes/safety-and-human-approval.Rmd
safety-and-human-approval.RmdAgent tools can read files, write files, call APIs, or trigger other
side effects. deepagentsr keeps those capabilities explicit
through three layers:
- side-effect metadata on tools;
- filesystem permission rules;
- human-in-the-loop interrupts and resume decisions.
Tool-level interrupts
Set interrupt = TRUE on a tool when every call should
pause before the function body runs.
sent <- new.env(parent = emptyenv())
sent$value <- FALSE
send_email <- deep_tool(
function(to) {
sent$value <- to
"sent"
},
name = "send_email",
description = "Send an email.",
side_effects = "network",
interrupt = TRUE
)
email_agent <- create_deep_agent(
model = fake_chat(list(
assistant_tool_call("send_email", list(to = "a@example.com")),
assistant_message("Email sent.")
)),
tools = list(send_email)
)
paused <- email_agent$invoke("Send the email.")
paused$status
#> [1] "interrupted"
paused$pending$tool
#> [1] "send_email"
sent$value
#> [1] FALSEThe side effect has not happened yet. Resume the same thread with one of the decision helpers.
resumed <- email_agent$resume(
paused$thread_id,
decision_edit(list(to = "b@example.com"))
)
resumed$status
#> [1] "completed"
sent$value
#> [1] "b@example.com"Use decision_approve() to run the original arguments,
decision_edit() to replace arguments,
decision_reject() to tell the model the call was rejected,
or decision_respond() to provide a human answer without
running the tool.
reject_agent <- create_deep_agent(
model = fake_chat(list(
assistant_tool_call("send_email", list(to = "a@example.com")),
assistant_message("I did not send the email.")
)),
tools = list(send_email)
)
rejected <- reject_agent$invoke("Send another email.")
reject_agent$resume(rejected$thread_id, decision_reject("Do not send email."))$text
#> [1] "I did not send the email."Filesystem permissions
fs_permission() rules apply to built-in file tools. A
rule can allow, deny, or interrupt operations on matching virtual
paths.
write_agent <- create_deep_agent(
model = fake_chat(list(
assistant_tool_call(
"write_file",
list(path = "/reports/out.md", content = "draft")
),
assistant_message("Report saved.")
)),
permissions = list(
fs_permission("write", "/reports/out.md", mode = "interrupt")
)
)
write_paused <- write_agent$invoke("Write the report.")
write_paused$status
#> [1] "interrupted"
write_paused$pending$kind
#> [1] "permission"
write_done <- write_agent$resume(write_paused$thread_id, decision_approve())
write_done$status
#> [1] "completed"Denied file operations fail immediately.
blocked_agent <- create_deep_agent(
model = fake_chat(list(
assistant_tool_call("read_file", list(path = "/secret.txt"))
)),
backend = memory_backend(list("/secret.txt" = "token")),
permissions = list(
fs_permission("read", "/secret.txt", mode = "deny")
)
)
blocked <- try(blocked_agent$invoke("Read the secret."), silent = TRUE)
inherits(blocked, "try-error")
#> [1] TRUEFilesystem backend safety
filesystem_backend() maps all virtual paths under one
configured host directory. It blocks path traversal and, by default,
common secret-like paths such as /.env.
root <- tempfile("deepagentsr-fs-")
safe_fs <- filesystem_backend(root)
safe_fs$write("/notes/a.txt", "safe")
safe_fs$read("/notes/a.txt")
#> [1] "safe"
secret_write <- try(safe_fs$write("/.env", "OPENAI_API_KEY=..."), silent = TRUE)
inherits(secret_write, "try-error")
#> [1] TRUELocal shell tool
Shell execution is not part of the default runtime.
local_dev_shell_tool() is an opt-in local development
convenience, not a security sandbox. Register it only for trusted tasks
and restrict commands with allowed_commands.
rscript <- file.path(R.home("bin"), "Rscript")
execute <- local_dev_shell_tool(
root_dir = tempdir(),
allowed_commands = rscript,
timeout = 10
)
agent <- create_deep_agent(
model = ellmer::chat_openai(model = "gpt-4.1"),
tools = list(execute),
interrupt_on = list(execute = interrupt_config())
)Auditing decisions
Interrupts, approvals, rejections, file reads, file writes, and tool results are emitted as events. Export a thread trace when you need an audit record.
trace_path <- tempfile(fileext = ".jsonl")
export_events_jsonl(write_agent, trace_path, thread_id = write_done$thread_id)
events <- read_events_jsonl(trace_path)
vapply(events, function(event) event$type, character(1))
#> [1] "agent_start" "tool_request" "interrupt_pending"
#> [4] "interrupt_resolved" "tool_request" "file_write"
#> [7] "tool_result" "model_message" "agent_end"