diff --git a/.autoducks/.installed.json b/.autoducks/.installed.json index 9c3ae9e..66ee2b1 100644 --- a/.autoducks/.installed.json +++ b/.autoducks/.installed.json @@ -2,14 +2,14 @@ "schemaVersion": 1, "source_repo": "deepducks/autoducks", "channel": "stable", - "ref": "v0.5.2", - "sha": "b062c9a0ab871d9ee5dbb0bce1a430b27dbc36ee", - "version": "0.5.2", - "installed_at": "2026-08-02T20:42:26Z", - "installed_by": "manual", + "ref": "4ac745e52ee271fff7ef8b677cfb875c5572ae44", + "sha": "4ac745e52ee271fff7ef8b677cfb875c5572ae44", + "version": "0.5.8", + "installed_at": "2026-08-04T00:06:37Z", + "installed_by": "autoducks-update.yml#30864506984", "previous": { - "ref": "3746c6b5f5e70ca1fa3526978784420e7e6a7081", - "sha": "3746c6b5f5e70ca1fa3526978784420e7e6a7081", - "version": "0.1.0" + "ref": "v0.5.2", + "sha": "b062c9a0ab871d9ee5dbb0bce1a430b27dbc36ee", + "version": "0.5.2" } } diff --git a/.autoducks/CHANGELOG.md b/.autoducks/CHANGELOG.md index 2dc9539..088579e 100644 --- a/.autoducks/CHANGELOG.md +++ b/.autoducks/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## [0.5.8] - 2026-08-03 + +### Fixed +- fix(agent): narrow the custom-agent lane's base-ref claim to what it delivers (#1179) + +## [0.5.7] - 2026-08-03 + +### Fixed +- fix(agent): Read belongs in the tool floor too (#1176) + +## [0.5.6] - 2026-08-03 + +### Fixed +- fix(agent): a tool floor the definition cannot replace away (#1174) + +## [0.5.5] - 2026-08-03 + +### Fixed +- fix(feedback): a max_turns retry hint for the agent lane (#1172) + +## [0.5.4] - 2026-08-03 + +### Fixed +- fix(agent): honour surface: both, and stop double-posting refusals (#1170) + +## [0.5.3] - 2026-08-02 + +### Security +- The custom-agent lane now reads agent definitions — and the `custom_agents` + config keys that grant them tools — from the base branch, never from the + checked-out tree. On a pull request the checkout is `refs/pull/N/head`, and a + definition body becomes the agent's prompt, so the previous behaviour could + execute unreviewed content with the repository's token. This applies to both + discovery and prompt assembly. (#1168) + +### Changed +- **Behaviour change for `/agent`:** a definition that exists only on a pull + request is no longer discovered, so an agent cannot be tried from the pull + request that introduces it. Merge the definition to the default branch first, + then use it. A `/agent` run on a pull request still works and still takes that + pull request as its context; only the definition comes from elsewhere. (#1168) +- Removed the machinery this replaces: the `verified` descriptor field, the tool + clamp and its `unverified_tools` floor, the unverified-definition refusal, and + the `custom_agents.allow_unverified` opt-in. None of these were part of a + release, so no configuration needs migrating. (#1168) + ## [0.5.2] - 2026-08-02 ### Fixed diff --git a/.autoducks/VERSION b/.autoducks/VERSION index cb0c939..659914a 100644 --- a/.autoducks/VERSION +++ b/.autoducks/VERSION @@ -1 +1 @@ -0.5.2 +0.5.8 diff --git a/.autoducks/agents/agent/defaults.json b/.autoducks/agents/agent/defaults.json index a145fb1..676e2ea 100644 --- a/.autoducks/agents/agent/defaults.json +++ b/.autoducks/agents/agent/defaults.json @@ -1,19 +1,30 @@ { "tools": [ - "Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "WebSearch", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "WebFetch", + "WebSearch", "Bash(read-only)", - "Bash(git log:*)", "Bash(git show:*)", "Bash(git diff:*)", "Bash(git status:*)", - "Bash(git blame:*)", "Bash(git rev-parse:*)", "Bash(git branch --list:*)", - "Bash(gh issue view:*)", "Bash(gh issue list:*)", "Bash(gh pr view:*)", - "Bash(gh pr diff:*)", "Bash(gh pr list:*)", "Bash(gh issue comment:*)" + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git diff:*)", + "Bash(git status:*)", + "Bash(git blame:*)", + "Bash(git rev-parse:*)", + "Bash(git branch --list:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr list:*)", + "Bash(gh issue comment:*)" ], - "unverified_tools": [ - "Read", "Glob", "Grep", - "Bash(read-only)", - "Bash(git log:*)", "Bash(git show:*)", "Bash(git diff:*)", "Bash(git status:*)", - "Bash(git blame:*)", "Bash(git rev-parse:*)", "Bash(git branch --list:*)", - "Bash(gh issue view:*)", "Bash(gh issue list:*)", "Bash(gh pr view:*)", - "Bash(gh pr diff:*)", "Bash(gh pr list:*)" + "required_tools": [ + "Read", + "Write" ], "max_turns": 30, "labels": [] diff --git a/.autoducks/agents/agent/pre.sh b/.autoducks/agents/agent/pre.sh index e4d2b69..13e29d6 100755 --- a/.autoducks/agents/agent/pre.sh +++ b/.autoducks/agents/agent/pre.sh @@ -28,9 +28,14 @@ status_comment::start "$ISSUE_NUM" # via the shared pre-failed marker + skip=true (no LLM call, post.sh no-ops). refuse() { local message="$1" - its::comment_issue "$ISSUE_NUM" "$message" || true + # The reason goes in the status comment only. Posting it as a standalone + # comment as well put identical text on the issue twice for every refusal: + # once from its::comment_issue, once as the status comment's failure body. react_to_comment "${COMMENT_ID:-}" "confused" - status_comment::fail "$ISSUE_NUM" "$message" 2>/dev/null || true + if ! status_comment::fail "$ISSUE_NUM" "$message" 2>/dev/null; then + # Nothing to edit — do not let a status-comment failure swallow the reason. + its::comment_issue "$ISSUE_NUM" "$message" || true + fi progress_labels::abort "$ISSUE_NUM" "Agent:running" 2>/dev/null || true touch "$AUTODUCKS_PRE_FAILED_MARKER" [[ -n "${GITHUB_OUTPUT:-}" ]] && echo "skip=true" >> "$GITHUB_OUTPUT" @@ -45,10 +50,22 @@ fi # ── Refusal #2: custom agents disabled repo-wide (never opens a definition) ─ AGENT_REPO_ROOT="${GITHUB_WORKSPACE:-$(pwd)}" AGENT_LIVE_CONFIG="${AUTODUCKS_CONFIG:-$AGENT_REPO_ROOT/.autoducks/autoducks.json}" -CUSTOM_AGENTS_ENABLED="true" -if [[ -f "$AGENT_LIVE_CONFIG" ]]; then - CUSTOM_AGENTS_ENABLED="$(jq -r 'if .custom_agents.enabled == false then "false" else "true" end' "$AGENT_LIVE_CONFIG" 2>/dev/null || echo true)" -fi + +# Config, like the definitions themselves, is read from the base branch — +# never the checked-out tree. `enabled` is the repo owner's kill switch, so a +# contributor must not be able to flip it back on in the same change that +# uses the lane. See discover-agents.sh for the full reasoning. +agent_base_config() { + if [[ -n "${AUTODUCKS_BASE_REF:-}" ]]; then + git -C "$AGENT_REPO_ROOT" show "$AUTODUCKS_BASE_REF:.autoducks/autoducks.json" 2>/dev/null || echo '{}' + elif [[ -f "$AGENT_LIVE_CONFIG" ]]; then + cat "$AGENT_LIVE_CONFIG" + else + echo '{}' + fi +} + +CUSTOM_AGENTS_ENABLED="$(agent_base_config | jq -r 'if .custom_agents.enabled == false then "false" else "true" end' 2>/dev/null || echo true)" if [[ "$CUSTOM_AGENTS_ENABLED" == "false" ]]; then refuse "🚫 Custom agents are disabled for this repository." fi @@ -97,6 +114,11 @@ if [[ "$GET_RC" -eq 4 || -z "$DESCRIPTOR_JSON" ]]; then No custom agent named \`${AGENT_NAME}\` was found. Run \`$(autoducks_command_for agent) \` naming one of the agents below. +Definitions are read from the default branch, so one that exists only in your +working tree, only on a feature branch, or under a \`.gitignore\`d path (\`.claude/\` +often is) will not appear here. Commit and merge it first — including the +pull request that introduces it, which cannot run its own agent. + $(build_catalog_comment)" fi @@ -122,13 +144,20 @@ fi # ── Refusal #4: surface mismatch (issue vs pr) ────────────────────────── CURRENT_SURFACE="issue" [[ "${IS_PR:-false}" == "true" ]] && CURRENT_SURFACE="pr" -if [[ "$DESC_SURFACE" != "$CURRENT_SURFACE" ]]; then - if [[ "$DESC_SURFACE" == "pr" ]]; then +# `both` means both, so it never mismatches. Comparing for equality alone +# refused it on every surface, and the message then reported it as +# `surface: issue` — the else branch only distinguished `pr` — so an agent +# declared `both`, invoked on an issue, was told it can only run on an issue. +case "$DESC_SURFACE" in + both) : ;; + "$CURRENT_SURFACE") : ;; + pr) refuse "🚫 \`${AGENT_NAME}\` is declared \`surface: pr\` and can only run from a pull request — re-run \`$(autoducks_command_for agent) ${AGENT_NAME}\` on the pull request instead." - else + ;; + *) refuse "🚫 \`${AGENT_NAME}\` is declared \`surface: issue\` and can only run from an issue — re-run \`$(autoducks_command_for agent) ${AGENT_NAME}\` on the issue instead." - fi -fi + ;; +esac # ── Tool resolution: discover-agents.sh already applied levels 1+2 # (custom_agents.agents..tools beats frontmatter tools outright, no @@ -138,6 +167,29 @@ fi # union of this lane's defaults.json with the repo-wide .defaults.tools). ── TOOLS_CSV="$(jq -r '.tools_effective // [] | join(",")' <<<"$DESCRIPTOR_JSON")" +# Whatever the definition asks for, the lane's own output contract still has +# to be satisfiable. The wrapper prompt requires the agent to write +# /tmp/agent-response.md, and a definition that declares `tools` REPLACES the +# lane default outright — so `tools: [WebSearch]` produced an agent that was +# ordered to write a file with no tool that can write, burned its whole turn +# budget on denied calls, and failed as `scope-missing`, blaming the +# definition for "not stating an output contract". +# +# The same applies to Read: the wrapper's `## Input` section lists the +# materialized context files and tells the agent to read them, so a definition +# without Read answers blind. That failed quietly rather than loudly — the +# agent produced a plausible answer and only mentioned in passing that it +# could not read the request, which it misdiagnosed as sandboxing. +# +# So required_tools is unioned in, always. It is deliberately not part of +# defaults.json's `tools`: that list is a *default* a definition may replace, +# while this one is the floor the lane needs to function at all. +REQUIRED_TOOLS_JSON="$(jq -c '.required_tools // []' "$AUTODUCKS_PINNED_ROOT/.autoducks/agents/agent/defaults.json" 2>/dev/null || echo '[]')" +if [[ -n "$TOOLS_CSV" && "$REQUIRED_TOOLS_JSON" != "[]" ]]; then + TOOLS_CSV="$(jq -rn --argjson req "$REQUIRED_TOOLS_JSON" --arg csv "$TOOLS_CSV" \ + '($csv | split(",")) + $req | unique_by(.) | join(",")')" +fi + DESC_MODEL="$(jq -r '.model // empty' <<<"$DESCRIPTOR_JSON")" DESC_EFFORT="$(jq -r '.effort // empty' <<<"$DESCRIPTOR_JSON")" DESC_MAX_TURNS="$(jq -r '.max_turns // empty' <<<"$DESCRIPTOR_JSON")" @@ -152,10 +204,32 @@ if [[ -n "${GITHUB_OUTPUT:-}" ]]; then } >> "$GITHUB_OUTPUT" fi -# ── Read the inherited definition body (the descriptor carries only -# body_bytes, not the text — discover-agents.sh scans the live tree, so the -# body is read from there too, not the pinned snapshot). ──────────────── -DEFINITION_FILE="$AGENT_REPO_ROOT/$DESC_SOURCE" +# ── Read the inherited definition body ───────────────────────────────── +# From AUTODUCKS_BASE_REF, the same source discover-agents.sh enumerated it +# from. This is the load-bearing read of the whole lane: the body below +# becomes the agent's prompt, so reading it from the checked-out tree would +# mean executing content from refs/pull/N/head — exactly what discovering +# from the base ref exists to prevent. Discovery and prompt assembly must +# never disagree about which tree a definition came from. +DEFINITION_FILE="$(mktemp)" +# Separate from the ERR trap above, which reports the failure rather than +# cleaning up. Harmless to skip on an ephemeral runner, but discover-agents.sh +# traps its own temp dir and this script should not be the odd one out. +trap 'rm -f "$DEFINITION_FILE"' EXIT +if [[ -n "${AUTODUCKS_BASE_REF:-}" ]]; then + if ! git -C "$AGENT_REPO_ROOT" show "$AUTODUCKS_BASE_REF:$DESC_SOURCE" > "$DEFINITION_FILE" 2>/dev/null; then + refuse "🚫 \`${AGENT_NAME}\` could not be read from the base branch (\`${DESC_SOURCE}\`). Custom agents only run definitions that are merged." + fi +else + # No `|| true`: an unreadable definition here used to leave the file empty + # and let the run continue into a real LLM call with an empty `## Role`. + # Unreachable from the shipped workflow, which always sets AUTODUCKS_BASE_REF, + # but reachable from local and test invocations — and a silent empty role is + # the worst of the available failures. Refuse, matching the branch above. + if ! cat "$AGENT_REPO_ROOT/$DESC_SOURCE" > "$DEFINITION_FILE" 2>/dev/null; then + refuse "🚫 \`${AGENT_NAME}\` could not be read from \`${DESC_SOURCE}\`." + fi +fi # extract_body FILE — strip a leading `---`-delimited frontmatter block if # present, same detection discover-agents.sh's own parse_definition uses diff --git a/.autoducks/core/config/discover-agents.sh b/.autoducks/core/config/discover-agents.sh index c6f3d02..c07921e 100755 --- a/.autoducks/core/config/discover-agents.sh +++ b/.autoducks/core/config/discover-agents.sh @@ -2,10 +2,12 @@ set -euo pipefail # ── Custom agent definition discovery ──────────────────────────────── -# Scans the *live working tree* (never $AUTODUCKS_PINNED_ROOT — the pinned -# snapshot contains only .autoducks) for user-authored agent definitions and -# emits a registry every downstream consumer (setup.sh, the trigger -# generator, the dispatcher) can share instead of re-parsing markdown itself. +# Scans $AUTODUCKS_BASE_REF for user-authored agent definitions and emits a +# registry every downstream consumer (setup.sh, the trigger generator, the +# dispatcher) can share instead of re-parsing markdown itself. With no base +# ref set — a local `setup.sh` run — it falls back to the live working tree +# (never $AUTODUCKS_PINNED_ROOT — the pinned snapshot contains only +# .autoducks). See "Where definitions come from" below for why the ref wins. # # Usage: # discover-agents.sh list # -> registry JSON on stdout @@ -57,6 +59,102 @@ REPO_ROOT="${GITHUB_WORKSPACE:-$(pwd)}" REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" CONFIG="${AUTODUCKS_CONFIG:-$REPO_ROOT/.autoducks/autoducks.json}" +# ── Where definitions come from ────────────────────────────────────── +# ALWAYS the base branch, never the checked-out tree. A definition body +# becomes the agent's prompt and its frontmatter asks for tools, so it is +# executable content: on a PR the checkout is refs/pull/N/head, and running +# from there would mean executing code that nobody has reviewed. +# +# Reading from the base ref is what makes the design's "merged, reviewed repo +# content" premise true by construction rather than something checked after +# the fact. It is also why this lane needs no per-definition clamp and no +# per-definition verification: the definition and every custom_agents key +# have only ever one source, and it is the reviewed one. +# +# It is NOT a blanket tool ceiling. A definition that declares no tools falls +# through to .defaults.tools, which autoducks-agent.yml reads via +# load-agent-defaults.sh from the pinned machinery snapshot — reviewed, but a +# different source than this one. +# +# The consequence, stated plainly: an agent cannot be tried from the pull +# request that introduces it. Merge the definition first, then use it. +# +# AUTODUCKS_BASE_REF unset means no ref to read from — a local `setup.sh` +# run — and discovery falls back to the working tree for reporting only. +DEFINITION_REF="${AUTODUCKS_BASE_REF:-}" + +# def_cat PATH — the file's contents as of DEFINITION_REF (or from disk when +# there is no ref). +def_cat() { + if [[ -n "$DEFINITION_REF" ]]; then + git -C "$REPO_ROOT" show "$DEFINITION_REF:$1" 2>/dev/null + else + cat "$REPO_ROOT/$1" 2>/dev/null + fi +} + +# def_config — autoducks.json as of DEFINITION_REF. Same reasoning: roots[] +# and the per-agent tool grants are privilege-bearing. +# Resolved once, here: every call site reads it in a command substitution, so +# assigning inside the function would only ever populate a subshell's copy. +if [[ -n "$DEFINITION_REF" ]]; then + _DEF_CONFIG="$(git -C "$REPO_ROOT" show "$DEFINITION_REF:.autoducks/autoducks.json" 2>/dev/null || echo '{}')" +elif [[ -f "$CONFIG" ]]; then + _DEF_CONFIG="$(cat "$CONFIG" 2>/dev/null || echo '{}')" +else + _DEF_CONFIG='{}' +fi +[[ -n "$_DEF_CONFIG" ]] || _DEF_CONFIG='{}' +def_config() { printf '%s' "$_DEF_CONFIG"; } + +# def_list ROOT KIND — " " for every definition +# under ROOT as of DEFINITION_REF, sorted by path, NUL-terminated. The mode is +# carried so a symlink (120000) can be refused: reading from a ref means there +# is no realpath to compare against the repo root. +# +# `ls-tree -z` is load-bearing, not a style choice. Without it core.quotePath +# (default true) C-quotes any path holding non-ASCII bytes, a quote, a +# backslash or a control character — `naïve.md` comes back as +# `"na\303\257ve.md"` — and the leading quote makes the entry match neither +# glob below, so it would be dropped silently. This file refuses; it does not +# skip. `-c core.quotePath=false` would only cover the non-ASCII case, and +# neither form protects the line-based reader from a path with a newline in +# it. -z gives raw bytes and a NUL record separator, which covers all of it. +def_list() { + local root="$1" kind="$2" + if [[ -n "$DEFINITION_REF" ]]; then + git -C "$REPO_ROOT" ls-tree -r -z "$DEFINITION_REF" -- "$root" 2>/dev/null \ + | while IFS= read -r -d '' _rec; do + # " \t" — split on the tab, then take the + # mode off the front of the metadata half. + local _meta="${_rec%%$'\t'*}" f="${_rec#*$'\t'}" mode + mode="${_meta%% *}" + if [[ "$kind" == "nested" ]]; then + # exactly //agent.md — no deeper nesting, matching the + # -mindepth 2 -maxdepth 2 the local branch uses. + [[ "$f" == "$root"/*/agent.md && "$f" != "$root"/*/*/* ]] || continue + else + [[ "$f" == "$root"/*.md && "$f" != "$root"/*/* ]] || continue + fi + printf '%s %s\0' "$mode" "$f" + done | sort -z -k2 + else + local depth_args=() + if [[ "$kind" == "nested" ]]; then + depth_args=(-mindepth 2 -maxdepth 2 -name 'agent.md') + else + depth_args=(-mindepth 1 -maxdepth 1 -name '*.md') + fi + # -print0 for the same reason as -z above: the record separator has to be + # a byte that cannot occur in a path. + find "$REPO_ROOT/$root" "${depth_args[@]}" -print0 2>/dev/null \ + | while IFS= read -r -d '' p; do + local f="${p#"$REPO_ROOT/"}" + if [[ -L "$REPO_ROOT/$f" ]]; then printf '120000 %s\0' "$f"; else printf '100644 %s\0' "$f"; fi + done | sort -z -k2 + fi +} + # ── Reserved names: built-in verbs/synonyms plus every configured # triggers.[] alias — a definition called architect.md must never # shadow /architect. ────────────────────────────────────────────────────── @@ -70,15 +168,16 @@ CONFIG="${AUTODUCKS_CONFIG:-$REPO_ROOT/.autoducks/autoducks.json}" _DA_SH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$_DA_SH_DIR/agent-roster.sh" +# Aliases come from the base config as well: reservation is what stops a +# definition from shadowing a real verb, so reading it from the checked-out +# tree would let a PR head delete an alias and free the name it protected. RESERVED_NAMES=" $AUTODUCKS_BUILTIN_VERBS " -if [[ -f "$CONFIG" ]]; then - for _a in "${AUTODUCKS_AGENTS[@]}"; do - while IFS= read -r _alias; do - [[ -z "$_alias" ]] && continue - RESERVED_NAMES+="$_alias " - done < <(jq -r --arg a "$_a" '.triggers[$a][]? // empty' "$CONFIG" 2>/dev/null) - done -fi +for _a in "${AUTODUCKS_AGENTS[@]}"; do + while IFS= read -r _alias; do + [[ -z "$_alias" ]] && continue + RESERVED_NAMES+="$_alias " + done < <(def_config | jq -r --arg a "$_a" '.triggers[$a][]? // empty' 2>/dev/null) +done is_reserved() { case "$RESERVED_NAMES" in @@ -96,12 +195,19 @@ ROOT_DIRS+=(".claude/agents"); ROOT_KINDS+=("flat") ROOT_DIRS+=(".agents"); ROOT_KINDS+=("flat") ROOT_DIRS+=(".github/agents"); ROOT_KINDS+=("flat") -if [[ -f "$CONFIG" ]]; then - while IFS= read -r _extra; do - [[ -z "$_extra" ]] && continue - ROOT_DIRS+=("$_extra"); ROOT_KINDS+=("flat") - done < <(jq -r '.custom_agents.roots[]? // empty' "$CONFIG" 2>/dev/null) -fi +# No `-f "$CONFIG"` guard: the roots come from def_config, so gating them on +# the *live* file existing would drop configured roots whenever the checkout +# has no autoducks.json — and would let deleting that file on a PR head +# change discovery. +while IFS= read -r _extra; do + [[ -z "$_extra" ]] && continue + # Trailing slashes have to go: the ref branch matches paths with `[[ ]]` + # globbing, where "extra-agents//*.md" never matches "extra-agents/x.md". + # The old filesystem glob tolerated the double slash, so a config that used + # to work would silently stop discovering anything. + while [[ "$_extra" == */ && "$_extra" != "/" ]]; do _extra="${_extra%/}"; done + ROOT_DIRS+=("$_extra"); ROOT_KINDS+=("flat") +done < <(def_config | jq -r '.custom_agents.roots[]? // empty' 2>/dev/null) # ── Small restricted string helpers (no eval, no external parser) ──── _trim() { @@ -287,7 +393,6 @@ emit_error() { ERRORS_JSON+=("$(jq -cn --arg source "$source" --arg reason "$reason" '{source:$source, reason:$reason}')") } -REPO_ROOT_REAL="$(realpath "$REPO_ROOT" 2>/dev/null || printf '%s' "$REPO_ROOT")" # process_definition process_definition() { @@ -303,13 +408,6 @@ process_definition() { return fi - local resolved - resolved="$(realpath "$file" 2>/dev/null || echo "")" - if [[ -z "$resolved" || "$resolved" != "$REPO_ROOT_REAL"/* ]]; then - emit_error "$rel_source" "symlink-escape" - return - fi - local size size="$(wc -c < "$file" | tr -d '[:space:]')" if (( size > 65536 )); then @@ -334,87 +432,21 @@ process_definition() { body_bytes="$(printf '%s' "$BODY_TEXT" | wc -c | tr -d '[:space:]')" # ── Config merge: custom_agents.agents. ────────────────────── + # One config source, the same ref the definition came from. local cfg="{}" - if [[ -f "$CONFIG" ]]; then - cfg="$(jq -c --arg n "$name" '.custom_agents.agents[$n] // {}' "$CONFIG" 2>/dev/null || echo '{}')" - fi - - # The tool grant is read from the BASE ref's autoducks.json, never the - # checked-out one. CONFIG resolves under $REPO_ROOT, which on a PR surface - # is the PR head — so a contributor who touches nothing but autoducks.json - # could add custom_agents.agents..tools = ["Bash"] for an already - # merged, unmodified definition. The definition-file clamp below would not - # fire (the file is byte-identical to base) and the escalation would land - # through the higher-precedence input instead. Everything else the config - # supplies (model, context, labels, …) is not a privilege and keeps reading - # the live file. - local cfg_tools_src="$cfg" - if [[ -n "${AUTODUCKS_BASE_REF:-}" ]]; then - local base_cfg - base_cfg="$(git -C "$REPO_ROOT" show "$AUTODUCKS_BASE_REF:.autoducks/autoducks.json" 2>/dev/null \ - | jq -c --arg n "$name" '.custom_agents.agents[$n] // {}' 2>/dev/null || echo '{}')" - [[ -n "$base_cfg" ]] || base_cfg='{}' - cfg_tools_src="$base_cfg" - fi + cfg="$(def_config | jq -c --arg n "$name" '.custom_agents.agents[$n] // {}' 2>/dev/null || echo '{}')" + [[ -n "$cfg" ]] || cfg='{}' # tools: config wins over frontmatter local tools_declared_json tools_effective_json cfg_tools_json tools_declared_json="$(arr_to_json "${FM_TOOLS_ARR[@]}")" - cfg_tools_json="$(jq -c 'if has("tools") then (.tools | if type=="array" then . else (split(",") | map(gsub("^\\s+|\\s+$";""))) end) else empty end' <<<"$cfg_tools_src" 2>/dev/null || echo "")" + cfg_tools_json="$(jq -c 'if has("tools") then (.tools | if type=="array" then . else (split(",") | map(gsub("^\\s+|\\s+$";""))) end) else empty end' <<<"$cfg" 2>/dev/null || echo "")" if [[ -n "$cfg_tools_json" ]]; then tools_effective_json="$cfg_tools_json" else tools_effective_json="$tools_declared_json" fi - # ── Unverified definitions cannot grant themselves tools ────────────── - # The design's no-ceiling rule rests on definitions being merged, reviewed - # repo content. On a PR surface that premise does not hold: the checkout is - # refs/pull/N/head, so a contributor can ship `surface: pr` + `tools: [Bash]` - # and have a maintainer's routine `/agent ` run it with contents:write - # and the app token. Nothing about that content has been reviewed. - # - # So the grant is clamped to the lane's own defaults.json whenever the - # definition does not appear, byte-identical, on the base ref. A definition - # merged on the default branch is unaffected and keeps the full no-ceiling - # behaviour; a new or edited one still runs — which is what makes testing an - # agent from its own PR possible — just with the lane default tool set. - local verified="unchecked" - if [[ -n "${AUTODUCKS_BASE_REF:-}" ]]; then - local rel="$rel_source" - # `git diff` rather than comparing the raw blob against the working-tree - # file: git applies the same clean/smudge filters to both sides. A raw - # byte compare reports every file as changed in any repo using - # core.autocrlf or a `text=auto eol=crlf` .gitattributes, which would - # clamp every custom agent in that repo for no reason. - if git -C "$REPO_ROOT" cat-file -e "$AUTODUCKS_BASE_REF:$rel" 2>/dev/null && - git -C "$REPO_ROOT" diff --quiet "$AUTODUCKS_BASE_REF" -- "$rel" 2>/dev/null; then - verified="base" - else - verified="unverified" - # Clamp to a dedicated `unverified_tools` set, NOT to the lane default. - # The lane default is calibrated for definitions that have been through - # review; it includes Write, Edit, WebFetch and WebSearch. An unverified - # body is injected into the prompt verbatim, so clamping to that set - # would still let attacker-authored text read the repository and encode - # what it finds into a WebFetch URL. `unverified_tools` drops network - # egress and filesystem writes; what remains is read and inspect. - # - # Residual risk, stated so the next reader does not over-trust this: - # the definition BODY is still unreviewed content driving the prompt. - # Clamped is not contained — it is a smaller blast radius. - local lane_defaults="${AUTODUCKS_ROOT:-$REPO_ROOT/.autoducks}/agents/agent/defaults.json" - local lane_tools_json="" - [[ -f "$lane_defaults" ]] && - lane_tools_json="$(jq -c '.unverified_tools // .tools // []' "$lane_defaults" 2>/dev/null || echo "")" - [[ -n "$lane_tools_json" ]] || lane_tools_json='[]' - if [[ "$tools_effective_json" != "$lane_tools_json" ]]; then - echo "::warning::discover-agents: '$name' ($rel) differs from $AUTODUCKS_BASE_REF — tool grant clamped to the lane default." >&2 - fi - tools_effective_json="$lane_tools_json" - fi - fi - # model: frontmatter wins over config, both alias-resolved local cfg_model_raw model_effective cfg_model_raw="$(jq -r '.model // empty' <<<"$cfg" 2>/dev/null || echo "")" @@ -475,7 +507,6 @@ process_definition() { --argjson context "$context_json" \ --arg surface "$surface_effective" \ --argjson labels "$labels_json" \ - --arg verified "$verified" \ --argjson body_bytes "$body_bytes" \ '{ name: $name, @@ -492,7 +523,6 @@ process_definition() { context: $context, surface: $surface, labels: $labels, - verified: $verified, body_bytes: $body_bytes }')" @@ -500,42 +530,50 @@ process_definition() { } # ── Scan roots in precedence order ─────────────────────────────────── -shopt -s nullglob +_DEF_TMP="$(mktemp -d)" +trap 'rm -rf "$_DEF_TMP"' EXIT for _ridx in "${!ROOT_DIRS[@]}"; do root="${ROOT_DIRS[$_ridx]}" kind="${ROOT_KINDS[$_ridx]}" precedence=$((_ridx + 1)) - abs_root="$REPO_ROOT/$root" - [[ -d "$abs_root" ]] || continue - declare -a candidates=() - if [[ "$kind" == "nested" ]]; then - candidates=("$abs_root"/*/agent.md) - else - candidates=("$abs_root"/*.md) - fi - (( ${#candidates[@]} == 0 )) && continue - - declare -a sorted=() - while IFS= read -r _c; do - sorted+=("$_c") - done < <(printf '%s\n' "${candidates[@]}" | sort) + # NUL-delimited: def_list emits raw paths, so no separator can appear inside + # one. Splitting mode off the front by hand keeps the rest of the record — + # spaces and all — intact as the path. + while IFS= read -r -d '' _rec; do + mode="${_rec%% *}" + rel_source="${_rec#* }" + [[ -n "$rel_source" ]] || continue - for file in "${sorted[@]}"; do - [[ -f "$file" ]] || continue - rel_source="${file#"$REPO_ROOT"/}" if [[ "$kind" == "nested" ]]; then - name="$(basename "$(dirname "$file")")" + name="$(basename "$(dirname "$rel_source")")" else - name="$(basename "$file" .md)" + name="$(basename "$rel_source" .md)" + fi + + # A symlink is content the ref does not actually vouch for — it points + # somewhere else, possibly outside the tree. Refuse rather than follow. + if [[ "$mode" == "120000" ]]; then + emit_error "$rel_source" "symlink-escape" + continue fi + + file="$_DEF_TMP/def.md" + # Only a genuine read failure is "unreadable". An empty file is NOT + # short-circuited here: process_definition checks the name against the + # reserved list before it looks at content, and a reserved name must be + # refused as reserved-name whatever the file holds. It reaches the same + # empty-body verdict a moment later, in the right order. + if ! def_cat "$rel_source" > "$file" 2>/dev/null; then + emit_error "$rel_source" "unreadable" + continue + fi + process_definition "$file" "$rel_source" "$root" "$precedence" "$name" - done + done < <(def_list "$root" "$kind") done -shopt -u nullglob - # ── Assemble + emit ─────────────────────────────────────────────────── json_array_of() { local -n _arr="$1" diff --git a/.autoducks/core/feedback/notify-failure.sh b/.autoducks/core/feedback/notify-failure.sh index b447b3b..0b49fa7 100755 --- a/.autoducks/core/feedback/notify-failure.sh +++ b/.autoducks/core/feedback/notify-failure.sh @@ -107,6 +107,10 @@ notify_failure() { reviewer) retry="re-run \`$(autoducks_command_for review) turns=$turns\`" ;; rework) retry="re-run \`$(autoducks_command_for rework) turns=$turns\`" ;; defer) retry="re-run \`$(autoducks_command_for defer) turns=$turns\`" ;; + # The agent lane has no partial branch and is not resumable: pointing + # it at /execute sent the user to the developer lane, which knows + # nothing about this run. Name the agent so the retry is copy-pasteable. + agent) retry="re-run \`$(autoducks_command_for agent) ${AUTODUCKS_AGENT_NAME:-} turns=$turns\`" ;; *) retry="\`$(autoducks_command_for execute) turns=$turns\` to resume from the partial branch with more turns" ;; esac ;; diff --git a/.autoducks/design/AGENTS.md b/.autoducks/design/AGENTS.md index 4b7c662..e15a4ed 100644 --- a/.autoducks/design/AGENTS.md +++ b/.autoducks/design/AGENTS.md @@ -166,11 +166,11 @@ Discovery ([`core/config/discover-agents.sh`](../core/config/discover-agents.sh) 3. `.agents/.md` 4. `.github/agents/.md` -Any roots listed in `custom_agents.roots[]` (config) are appended after root 4, in order, scanned the same way as roots 2–4 (flat `.md`). A definition's frontmatter (`name`, `description`, `model`, `effort`, `max_turns`, `surface`, `tools`, `context`, `labels`) is read by a small, `eval`-free scalar/array parser — never a YAML interpreter that could execute tags. `custom_agents.agents.` in `.autoducks/autoducks.json` can override a definition: `tools` from config wins over frontmatter outright (no union/intersection); `model`/`effort`/`max_turns`/`context` from frontmatter win over config. +Any roots listed in `custom_agents.roots[]` (config) are appended after root 4, in order, scanned the same way as roots 2–4 (flat `.md`). A definition's frontmatter (`name`, `description`, `model`, `effort`, `max_turns`, `surface`, `tools`, `context`, `labels`) is read by a small, `eval`-free scalar/array parser — never a YAML interpreter that could execute tags. `custom_agents.agents.` in `.autoducks/autoducks.json` can override a definition: `tools` from config wins over frontmatter outright (no union/intersection), except that `required_tools` from `.autoducks/agents/agent/defaults.json` is always unioned in — it is the floor the lane's own output contract needs (`Write`, for `/tmp/agent-response.md`), so a definition cannot replace away the ability to answer at all; `model`/`effort`/`max_turns`/`context` from frontmatter win over config. -**Verification and the tool clamp.** The no-ceiling rule above holds only for definitions that are merged, reviewed repo content. Discovery scans the live checkout, which on a PR surface is `refs/pull/N/head`, so each definition is compared against `AUTODUCKS_BASE_REF` (`origin/`) with `git diff`. The descriptor records the outcome in `verified`: `base` when the file matches, `unverified` when it was added or edited, `unchecked` when no base ref is configured (a local `setup.sh` run). An `unverified` definition falls back to `unverified_tools` in `.autoducks/agents/agent/defaults.json` — a strictly read-only set with no `Write`, `Edit`, `WebFetch` or `WebSearch` — regardless of what its frontmatter or config asks for. Because the config is the higher-precedence source, the `tools` grant is read from the **base ref's** `autoducks.json`, not the checked-out one; otherwise a PR touching only that file could escalate an otherwise unmodified definition. Everything else the config supplies is not a privilege and is read from the live file. Note what this does *not* do: the definition body still drives the prompt, so an `unverified` run is narrowed, not contained. +**Definitions come from the base branch, always.** Discovery reads each definition — and the `custom_agents` config keys that grant tools — from `AUTODUCKS_BASE_REF`, never from the checked-out tree. A definition body becomes the agent's prompt and its frontmatter asks for tools, so it is executable content; on a pull request the checkout is `refs/pull/N/head`, and running from there would mean executing code nobody has reviewed. Reading from the base branch is what makes the "merged, reviewed repo content" premise above true *by construction*, and is why this lane needs no per-definition verification and no per-definition clamp. What it is **not** is a blanket tool ceiling: a definition that declares no `tools` falls through to `.defaults.tools`, which the workflow loads from the pinned machinery snapshot rather than from this ref, and [`resolve-prompt.sh`](../core/config/resolve-prompt.sh)'s `.autoducks/custom/` prompt overlays are live-tree reads, as in every lane. Both sources are reviewed content in the ordinary case; neither is guaranteed by the base-ref read described here. The consequence, stated plainly: an agent cannot be tried from the pull request that introduces it — merge the definition first, then use it. A `/agent` run on a pull request still works and still sees that pull request as its context; only the definition comes from elsewhere. -**Concurrency, and one behaviour worth knowing.** The lane's concurrency group is the issue-or-PR number alone, with `cancel-in-progress: false`, so invocations on one issue serialize — including different custom agents. The agent name cannot be part of the key: on a comment trigger the expression engine cannot split it out of the body, and keying on the body itself let two `/agent foo` invocations that differed only in steering text race two pushes to the same `agent//…` branch. The consequence to know about: GitHub keeps at most one *pending* run per group, so if one run is executing and one is already queued, a third `/agent …` on the same issue is cancelled before its first step. There is no 👀, no status comment and no failure notice — the command simply does not happen, and has to be re-issued. A name must match `^[a-z0-9][a-z0-9-]{0,63}$` and can never shadow a built-in verb, synonym, or configured `triggers.[]` alias — a collision is a discovery error (`reserved-name`), not a silent skip, same treatment as an invalid name, an empty body, a symlink escaping the repo root, or a definition over 64KB. +**Concurrency, and one behaviour worth knowing.** The lane's concurrency group is the issue-or-PR number alone, with `cancel-in-progress: false`, so invocations on one issue serialize — including different custom agents. The agent name cannot be part of the key: on a comment trigger the expression engine cannot split it out of the body, and keying on the body itself let two `/agent foo` invocations that differed only in steering text race two pushes to the same `agent//…` branch. The consequence to know about: GitHub keeps at most one *pending* run per group, so if one run is executing and one is already queued, a third `/agent …` on the same issue is cancelled before its first step. There is no 👀, no status comment and no failure notice — the command simply does not happen, and has to be re-issued. A name must match `^[a-z0-9][a-z0-9-]{0,63}$` and can never shadow a built-in verb, synonym, or configured `triggers.[]` alias — a collision is a discovery error (`reserved-name`), not a silent skip, same treatment as an invalid name (`invalid-name`), an empty body (`empty-body`), a definition that cannot be read at the ref (`unreadable`), a symlinked definition (`symlink-escape`), or a definition over 64KB (`too-large`). Symlinks are refused by tree mode, so the refusal covers any symlink, not only one whose target escapes the repo: reading from a ref leaves no realpath to compare against the repo root. The LLM step itself is restricted to read-only `git`/`gh` exploration (`git log/show/diff/status/blame/rev-parse/branch --list`, `gh issue view/list`, `gh pr view/diff/list`, `gh issue comment`) plus a writable filesystem, and has exactly one output contract: write `/tmp/agent-response.md`. All mutation happens afterward, in `post.sh`: when the working tree is unchanged, the response is posted as an issue comment and nothing else happens; when the agent changed files, `post.sh` commits them onto a fresh `agent//-` branch (see [Branch Naming](#branch-naming)) and opens a PR. That PR is **not a pipeline deliverable** — it references the triggering issue (`Ref #N`, never a closing keyword) and is left open for human review rather than auto-merged. `Agent:running` → `Agent:done` (see [Labels](#labels)) brackets the run like the pipeline agents, even though the lane sits outside the planning-to-execution pipeline. diff --git a/.autoducks/runtimes/github-actions/autoducks-agent.yml b/.autoducks/runtimes/github-actions/autoducks-agent.yml index f2ea0a0..695c856 100644 --- a/.autoducks/runtimes/github-actions/autoducks-agent.yml +++ b/.autoducks/runtimes/github-actions/autoducks-agent.yml @@ -158,10 +158,18 @@ jobs: COMMENT_ID: ${{ github.event.comment.id || '0' }} REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # AUTODUCKS_ROOT is set, not just the script path. Without it + # load-agent-defaults.sh resolves `_root=.autoducks` relative to the CWD + # (load-agent-defaults.sh:8) — i.e. the PR head — so the `tools=` value a + # definition without its own `tools` falls through to came from + # `.autoducks/autoducks.json` `.defaults.tools` on unreviewed content, in + # a job holding contents: write. Reading it from the pin (merge-base with + # the base branch, see snapshot-machinery.sh) puts the fallback grant on + # the same reviewed footing as the definition itself. - name: Load agent defaults id: agent if: steps.authz.outcome == 'success' - run: AUTODUCKS_AGENT=agent bash "${AUTODUCKS_PINNED_ROOT:-.}/.autoducks/core/config/load-agent-defaults.sh" >> "$GITHUB_OUTPUT" + run: AUTODUCKS_AGENT=agent AUTODUCKS_ROOT="${AUTODUCKS_PINNED_ROOT:-.}/.autoducks" bash "${AUTODUCKS_PINNED_ROOT:-.}/.autoducks/core/config/load-agent-defaults.sh" >> "$GITHUB_OUTPUT" - name: Set context id: ctx if: steps.authz.outcome == 'success' @@ -229,11 +237,19 @@ jobs: AGENT_NAME: ${{ steps.ctx.outputs.agent_name }} AGENT_NAME_ERROR: ${{ steps.ctx.outputs.agent_name_error }} STEERING_PROMPT: ${{ steps.ctx.outputs.steering_prompt }} - # Definitions are only trusted with their declared tool grant when - # they match this ref byte-for-byte. On an issue comment the checkout - # already IS this ref, so nothing is clamped; on a PR the checkout is - # refs/pull/N/head and any added or edited definition falls back to - # the lane default set. See discover-agents.sh's `verified` field. + # Agent definitions and the config keys that grant them tools are + # read from THIS ref, never from the checked-out tree. A definition + # body becomes the agent's prompt, so on a PR — where the checkout + # is refs/pull/N/head — running from the tree would execute + # unreviewed content. Reading from the base branch is what makes + # the design's "merged, reviewed repo content" premise true by + # construction, and why this lane needs no per-definition clamp. + # + # Scope, precisely: the definition body and every custom_agents key. + # The fallback tool grant a definition without `tools` lands on comes + # from the pinned machinery snapshot (see the Load agent defaults + # step), and resolve-prompt.sh's `.autoducks/custom/` overlays are + # still live-tree reads, as in every lane. Not a blanket ceiling. AUTODUCKS_BASE_REF: origin/${{ github.event.repository.default_branch }} GH_TOKEN: ${{ steps.apptoken.outputs.value || secrets.AUTODUCKS_PAT || secrets.GITHUB_TOKEN }} - name: User pre hook diff --git a/.github/workflows/autoducks-agent.yml b/.github/workflows/autoducks-agent.yml index f2ea0a0..695c856 100644 --- a/.github/workflows/autoducks-agent.yml +++ b/.github/workflows/autoducks-agent.yml @@ -158,10 +158,18 @@ jobs: COMMENT_ID: ${{ github.event.comment.id || '0' }} REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # AUTODUCKS_ROOT is set, not just the script path. Without it + # load-agent-defaults.sh resolves `_root=.autoducks` relative to the CWD + # (load-agent-defaults.sh:8) — i.e. the PR head — so the `tools=` value a + # definition without its own `tools` falls through to came from + # `.autoducks/autoducks.json` `.defaults.tools` on unreviewed content, in + # a job holding contents: write. Reading it from the pin (merge-base with + # the base branch, see snapshot-machinery.sh) puts the fallback grant on + # the same reviewed footing as the definition itself. - name: Load agent defaults id: agent if: steps.authz.outcome == 'success' - run: AUTODUCKS_AGENT=agent bash "${AUTODUCKS_PINNED_ROOT:-.}/.autoducks/core/config/load-agent-defaults.sh" >> "$GITHUB_OUTPUT" + run: AUTODUCKS_AGENT=agent AUTODUCKS_ROOT="${AUTODUCKS_PINNED_ROOT:-.}/.autoducks" bash "${AUTODUCKS_PINNED_ROOT:-.}/.autoducks/core/config/load-agent-defaults.sh" >> "$GITHUB_OUTPUT" - name: Set context id: ctx if: steps.authz.outcome == 'success' @@ -229,11 +237,19 @@ jobs: AGENT_NAME: ${{ steps.ctx.outputs.agent_name }} AGENT_NAME_ERROR: ${{ steps.ctx.outputs.agent_name_error }} STEERING_PROMPT: ${{ steps.ctx.outputs.steering_prompt }} - # Definitions are only trusted with their declared tool grant when - # they match this ref byte-for-byte. On an issue comment the checkout - # already IS this ref, so nothing is clamped; on a PR the checkout is - # refs/pull/N/head and any added or edited definition falls back to - # the lane default set. See discover-agents.sh's `verified` field. + # Agent definitions and the config keys that grant them tools are + # read from THIS ref, never from the checked-out tree. A definition + # body becomes the agent's prompt, so on a PR — where the checkout + # is refs/pull/N/head — running from the tree would execute + # unreviewed content. Reading from the base branch is what makes + # the design's "merged, reviewed repo content" premise true by + # construction, and why this lane needs no per-definition clamp. + # + # Scope, precisely: the definition body and every custom_agents key. + # The fallback tool grant a definition without `tools` lands on comes + # from the pinned machinery snapshot (see the Load agent defaults + # step), and resolve-prompt.sh's `.autoducks/custom/` overlays are + # still live-tree reads, as in every lane. Not a blanket ceiling. AUTODUCKS_BASE_REF: origin/${{ github.event.repository.default_branch }} GH_TOKEN: ${{ steps.apptoken.outputs.value || secrets.AUTODUCKS_PAT || secrets.GITHUB_TOKEN }} - name: User pre hook diff --git a/scripts/setup.sh b/scripts/setup.sh index 41ae7ce..ae1fd6a 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -743,8 +743,14 @@ echo "" # .autoducks/custom/agents, .claude/agents, .agents, .github/agents, and any # custom_agents.roots[]) and surfaces what it finds. A non-empty errors[] # means at least one definition was refused (bad name, reserved name, -# oversized, empty body, or a symlink escaping the repo) — fail so the +# oversized, empty body, unreadable, or a symlinked definition) — fail so the # operator fixes it before the definition is silently unusable at runtime. +# +# This runs with no AUTODUCKS_BASE_REF, so discovery reads the working tree +# while the runtime reads the default branch. Without the mergeability warning +# below the two disagree in the most confusing possible way: setup says +# "discovered", and the agent then answers "no custom agent named was +# found" for a file the operator is looking at on disk. echo "[15/15] Custom agent discovery" DISCOVER_AGENTS="$SCRIPT_DIR/../.autoducks/core/config/discover-agents.sh" if [[ ! -f "$DISCOVER_AGENTS" ]]; then @@ -760,7 +766,36 @@ else pass "No custom agent definitions found" else pass "Discovered $AGENT_COUNT custom agent definition(s):" - jq -r '.agents[] | " - \(.name) (\(.source))\(if .shadowed then " [shadowed]" else "" end)"' <<<"$REGISTRY_JSON" + # Resolve the default branch once. With no origin/HEAD — no remote, or a + # clone that never fetched — there is nothing to compare against, so the + # mergeability annotation is simply omitted. + DEFAULT_REF="" + if _dh="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)"; then + DEFAULT_REF="${_dh#refs/remotes/}" + fi + UNMERGED=0 + while IFS=$'\t' read -r _name _source _shadowed; do + [[ -n "$_name" ]] || continue + _note="" + [[ "$_shadowed" == "true" ]] && _note=" [shadowed]" + if [[ -n "$DEFAULT_REF" ]]; then + if ! git cat-file -e "$DEFAULT_REF:$_source" 2>/dev/null; then + _note="$_note ← not on $DEFAULT_REF, will NOT run" + UNMERGED=$((UNMERGED + 1)) + elif ! git diff --quiet "$DEFAULT_REF" -- "$_source" 2>/dev/null; then + _note="$_note ← differs from $DEFAULT_REF, the merged body runs" + UNMERGED=$((UNMERGED + 1)) + fi + fi + printf ' - %s (%s)%s\n' "$_name" "$_source" "$_note" + done < <(jq -r '.agents[] | [.name, .source, (.shadowed // false)] | @tsv' <<<"$REGISTRY_JSON") + if [[ "$UNMERGED" -gt 0 ]]; then + # Deliberately `manual`, not `fail`: authoring a definition on a branch + # is a legitimate intermediate state. What the operator needs is to know + # that this check reads the working tree while the runtime reads the + # default branch, so "discovered" here does not mean "runnable" there. + manual "$UNMERGED custom agent definition(s) are not merged on $DEFAULT_REF — the runtime reads definitions from the default branch, so they will not run (or will run an older body) until merged" + fi fi if [[ "$ERROR_COUNT" -gt 0 ]]; then fail "$ERROR_COUNT custom agent definition(s) failed validation:"