# guard-agent-background: an agent hook by Scalably

Canonical: https://scalably.io/hooks/guard-agent-background
Source: https://github.com/scalably-io/agent-hooks/blob/v1.0.0/hooks/guard-agent-background.sh
Runs on: PreToolUse on Agent|Task
Release: v1.0.0 at commit 53dcece
Integrity: sha256 of the script served at https://scalably.io/hooks/guard-agent-background.sh is d934e3b1dcefc6d8c4b9c3dc9b0c0f23c81e74e5d433803206353c7a56204390. Verify: curl -s https://scalably.io/hooks/guard-agent-background.sh | shasum -a 256
This is the machine-readable representation of the page at the canonical URL. Same facts, denser format.

## Summary

PreToolUse hook: a spawned subagent must run in the foreground, explicitly.

## Install

```
/plugin marketplace add scalably-io/agent-skills
/plugin install agent-hooks@scalably-agent-skills
```

## What it does

The problem it solves: a backgrounded subagent outlives the turn that spawned
it. When the session ends, or the environment hosting it is torn down on an
idle timer, the agent is killed mid-work and everything it had done is lost,
with no error anywhere. Foreground runs are safe: the parent waits on them, so
the session stays alive for the whole run.

Why it demands an explicit false rather than just denying true: background is
the DEFAULT when the flag is omitted. A guard that only denies an explicit
true therefore lets through exactly the calls that cause the problem. Refusing
anything that is not a literal false also covers truthy strings and any future
change to the default in either direction.

Remote isolation always backgrounds, whatever the flag says, so it is denied
too.

If the work genuinely is long-running, do not background a subagent for it.
Start a job that returns an identifier you can poll, so the work survives
independently of the conversation.

Exit: always 0. The decision travels in the JSON.

## The whole script

```bash
#!/usr/bin/env bash
# PreToolUse hook: a spawned subagent must run in the foreground, explicitly.
#
# The problem it solves: a backgrounded subagent outlives the turn that spawned
# it. When the session ends, or the environment hosting it is torn down on an
# idle timer, the agent is killed mid-work and everything it had done is lost,
# with no error anywhere. Foreground runs are safe: the parent waits on them, so
# the session stays alive for the whole run.
#
# Why it demands an explicit false rather than just denying true: background is
# the DEFAULT when the flag is omitted. A guard that only denies an explicit
# true therefore lets through exactly the calls that cause the problem. Refusing
# anything that is not a literal false also covers truthy strings and any future
# change to the default in either direction.
#
# Remote isolation always backgrounds, whatever the flag says, so it is denied
# too.
#
# If the work genuinely is long-running, do not background a subagent for it.
# Start a job that returns an identifier you can poll, so the work survives
# independently of the conversation.
#
# Exit: always 0. The decision travels in the JSON.

input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
case "$tool" in
  Agent|Task) ;;
  *) exit 0 ;;
esac

# `type` distinguishes the boolean false from the string "false".
#
# Do NOT read the value with jq's `//` operator here. It returns its right-hand
# side when the left is null OR false, so `run_in_background // "absent"` maps a
# literal false to "absent" and this guard would deny the one call it exists to
# allow. `tostring` is unambiguous, and the type check above it rejects strings.
bg_type=$(printf '%s' "$input" | jq -r '.tool_input.run_in_background | type' 2>/dev/null) || bg_type="null"
bg_val=$(printf '%s' "$input" | jq -r '.tool_input.run_in_background | tostring' 2>/dev/null) || bg_val="null"
isolation=$(printf '%s' "$input" | jq -r '.tool_input.isolation // ""' 2>/dev/null) || isolation=""

if [ "$bg_type" = "boolean" ] && [ "$bg_val" = "false" ] && [ "$isolation" != "remote" ]; then
  exit 0
fi

reason='Subagents must run in the foreground. A backgrounded subagent outlives the turn that spawned it, and is killed when the session ends or its host is torn down, losing everything it had done with no error.

Retry the same call with run_in_background set explicitly to false. Omitting it means background, and will be denied again.

If the job is genuinely long-running, do not background a subagent for it. Start a job that returns an identifier you can poll, so the work survives independently of this conversation.'

jq -nc --arg r "$reason" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: $r
  }
}' 2>/dev/null
exit 0
```
