Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .agentic-workflow/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Agent safety hooks

Repository-scoped, opt-in adapters for the agentic workflow. Every platform
normalizes its payload into `guard-command.sh`; the policy blocks obvious
environment disclosure, direct environment-file reads, and direct merge
commands. Legitimate assignments such as `export NODE_ENV=test` remain allowed.

## Activate one or more adapters

| Agent | Activate |
|---|---|
| Claude Code | merge `.claude/settings.json.example`'s `PreToolUse` block into `.claude/settings.json` |
| Cursor | copy `.cursor/hooks.json.example` to `.cursor/hooks.json` or merge its `beforeShellExecution` entry |
| Copilot | copy `.github/hooks/agentic-workflow.json.example` to `.github/hooks/agentic-workflow.json` |
| OpenCode | copy `.opencode/plugins/agentic-workflow-guard.ts.example` to `.opencode/plugins/agentic-workflow-guard.ts` |

The shell adapters require `jq`; OpenCode uses Bun's built-in process API. Run:

```sh
bash .agentic-workflow/hooks/tests/test-command-guard.sh
```

Do not activate or overwrite a customized platform hook without explicit
maintainer consent. `init-workspace` discovers the platform, asks, installs
additively, and reports residuals.

## Automated merge

Direct merge commands are always blocked. `ship-roadmap --fullauto` is the sole
automated merge authority and calls `fullauto-merge.sh` only after a fresh
SHA-bound audit. The wrapper creates a transient marker under the git common
directory, removes it on every exit, and posts an idempotent audit comment on
the merged PR. It never creates a persistent `.automerge` permission.

These hooks are defense-in-depth, not a sandbox. Keep secret-manager controls
and forge branch protection/rulesets enabled.
24 changes: 24 additions & 0 deletions .agentic-workflow/hooks/adapters/copilot-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash

set -u

hooks_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)

if ! parsed=$("$hooks_dir/adapters/normalize-hook-payload.sh" 2>/dev/null); then
printf '%s\n' '{"continue":false,"stopReason":"Blocked by agentic-workflow safety policy: invalid hook payload"}'
exit 0
fi

command_text=$(printf '%s' "$parsed" | jq -er '.[0]')
file_path=$(printf '%s' "$parsed" | jq -er '.[1]')

set +e
reason=$("$hooks_dir/guard-command.sh" --command "$command_text" --path "$file_path" 2>&1)
status=$?
set -e

if [ "$status" -ne 0 ]; then
jq -cn --arg reason "$reason" '{continue:false,stopReason:$reason}'
fi

exit 0
24 changes: 24 additions & 0 deletions .agentic-workflow/hooks/adapters/normalize-hook-payload.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash

set -u

input=$(cat)

if ! command -v jq >/dev/null 2>&1; then
echo "jq is required by this hook adapter" >&2
exit 2
fi

printf '%s' "$input" | jq -er '
if type != "object" then error("hook payload must be an object")
else
[
(.tool_input.command // .input.command // .toolArgs.command // .args.command // .command // ""),
(.tool_input.file_path // .tool_input.path // .input.file_path // .input.path // .toolArgs.file_path // .toolArgs.path // .args.file_path // .args.path // .file_path // .path // "")
]
| if all(.[]; type == "string") and any(.[]; length > 0)
then @json
else error("hook payload must contain a recognized command or path")
end
end
' 2>/dev/null
15 changes: 15 additions & 0 deletions .agentic-workflow/hooks/adapters/pre-tool-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash

set -u

hooks_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)

if ! parsed=$("$hooks_dir/adapters/normalize-hook-payload.sh" 2>/dev/null); then
echo "Blocked by agentic-workflow safety policy: invalid hook payload" >&2
exit 2
fi

command_text=$(printf '%s' "$parsed" | jq -er '.[0]')
file_path=$(printf '%s' "$parsed" | jq -er '.[1]')

exec "$hooks_dir/guard-command.sh" --command "$command_text" --path "$file_path"
130 changes: 130 additions & 0 deletions .agentic-workflow/hooks/fullauto-merge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash

set -euo pipefail

pr=""
run_id=""
method="merge"

fail() {
printf 'fullauto-merge: %s\n' "$1" >&2
exit 1
}

while [ "$#" -gt 0 ]; do
case "$1" in
--pr) [ "$#" -ge 2 ] || fail "--pr requires a value"; pr=$2; shift 2 ;;
--run-id) [ "$#" -ge 2 ] || fail "--run-id requires a value"; run_id=$2; shift 2 ;;
--method) [ "$#" -ge 2 ] || fail "--method requires a value"; method=$2; shift 2 ;;
*) echo "fullauto-merge: unknown argument: $1" >&2; exit 2 ;;
esac
done

[ -n "$pr" ] && [ -n "$run_id" ] || fail "--pr and --run-id are required"
printf '%s' "$pr" | grep -Eq '^[0-9]+$' || fail "PR must be numeric"
printf '%s' "$run_id" | grep -Eq '^[A-Za-z0-9._-]+$' || fail "run id is invalid"
case "$method" in merge|squash|rebase) ;; *) fail "method must be merge, squash, or rebase" ;; esac

command -v jq >/dev/null 2>&1 || fail "jq is required"
command -v gh >/dev/null 2>&1 || fail "gh is required"
[ -z "$(git status --porcelain)" ] || fail "working tree is not clean"
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) || fail "current branch has no upstream"
git fetch --quiet
sync_counts=$(git rev-list --left-right --count "$upstream...HEAD")
[ "$sync_counts" = $'0\t0' ] || fail "branch is not synchronized with its remote"

pr_json=$(gh pr view "$pr" --json number,url,state,baseRefName,headRefOid,mergeable,statusCheckRollup,comments,headRepository,headRefName)
head_sha=$(printf '%s' "$pr_json" | jq -r '.headRefOid')
remote_head=$(printf '%s' "$pr_json" | jq -r '.headRefOid')
remote_base=$(printf '%s' "$pr_json" | jq -r '.baseRefName')
remote_state=$(printf '%s' "$pr_json" | jq -r '.state')
pr_url=$(printf '%s' "$pr_json" | jq -r '.url')
repo_owner=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
default_base=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name')
[ -n "$head_sha" ] && [ "$head_sha" != "null" ] || fail "PR head is unavailable"
[ "$remote_base" = "$default_base" ] || fail "PR base is not the forge default branch"
[ "$remote_head" = "$head_sha" ] || fail "PR head changed during validation"

audit_marker="<!-- audit-pr:merge-ready sha=$head_sha -->"
marker="<!-- agentic-workflow:automerge head=$head_sha -->"

comment_file=""
attempt_marker=""
trap 'rm -f "${comment_file:-}"; rm -f "${attempt_marker:-}"' EXIT HUP INT TERM

printf '%s' "$pr_json" | jq -e --arg marker "$audit_marker" \
'[.comments[]?.body | contains($marker)] | any' >/dev/null \
|| fail "fresh SHA-bound audit MERGE-READY evidence is unavailable"

decision_json=$(gh api "repos/$repo_owner/contents/docs/features/SHIP_DECISIONS.md?ref=$head_sha")
decision_text=$(printf '%s' "$decision_json" | jq -r '.content // empty' | tr -d '\n' | base64 -d 2>/dev/null)
printf '%s' "$decision_text" | grep -Eqi '^merge:[[:space:]]*fullauto[[:space:]]*$' \
|| fail "PR head does not authorize merge: fullauto"

[ "$remote_head" = "$head_sha" ] || fail "remote head does not match the audited SHA"
comment_exists() {
printf '%s' "$1" | jq -e --arg marker "$marker" '[.comments[]?.body | contains($marker)] | any' >/dev/null
}

post_comment() {
merge_sha=$1
comment_file=$(mktemp "${TMPDIR:-/tmp}/agentic-workflow-automerge.XXXXXX")
tick='`'
{
printf '%s\n' "$marker"
printf '%s\n' '## agentic-workflow: auto-merged'
printf '\n- **Mode:** %sship-roadmap --fullauto%s\n' "$tick" "$tick"
printf -- '- **Run:** %s%s%s\n' "$tick" "$run_id" "$tick"
printf -- '- **Audited head:** %s%s%s\n' "$tick" "$head_sha" "$tick"
printf -- '- **Merge commit:** %s%s%s\n' "$tick" "$merge_sha" "$tick"
printf -- '- **Audit trail:** this comment is the durable automerge log; direct merge commands remained blocked.\n'
} > "$comment_file"
gh pr comment "$pr" --body-file "$comment_file" >/dev/null
}

if [ "$remote_state" = "MERGED" ]; then
if ! comment_exists "$pr_json"; then
merged_json=$(gh pr view "$pr" --json mergeCommit)
post_comment "$(printf '%s' "$merged_json" | jq -r '.mergeCommit.oid')"
fi
printf 'MERGED %s @ %s (already merged; comment reconciled)\n' "$pr_url" "$head_sha"
exit 0
fi

[ "$remote_state" = "OPEN" ] || fail "PR is not open"
[ "$(printf '%s' "$pr_json" | jq -r '.mergeable')" != "CONFLICTING" ] || fail "PR is conflicting"

fresh_head=$(gh pr view "$pr" --json headRefOid -q '.headRefOid')
[ "$fresh_head" = "$head_sha" ] || fail "PR head changed during validation (was $head_sha, now $fresh_head)"
remote_head="$fresh_head"

check_count=$(printf '%s' "$pr_json" | jq '.statusCheckRollup | length')
if [ "$check_count" -eq 0 ]; then
[ "${AGENTIC_WORKFLOW_LOCAL_GATE_SHA:-}" = "$head_sha" ] || fail "no CI checks and no fresh local gate for the audited SHA"
else
printf '%s' "$pr_json" | jq -e '
[.statusCheckRollup[] |
((.conclusion // .state // "") | ascii_upcase) as $result |
($result == "SUCCESS" or $result == "NEUTRAL" or $result == "SKIPPED")
] | all
' >/dev/null || fail "CI is not green on the audited SHA"
fi

git_common=$(git rev-parse --git-common-dir)
case "$git_common" in /*) ;; *) git_common="$(pwd)/$git_common" ;; esac
marker_dir="$git_common/agentic-workflow"
mkdir -p "$marker_dir"
umask 077
attempt_marker="$marker_dir/automerge-$run_id"
printf 'run=%s\npr=%s\nhead=%s\n' "$run_id" "$pr" "$head_sha" > "$attempt_marker"

gh pr merge "$pr" "--$method" --match-head-commit "$head_sha" || fail "merge failed (check PR state and permissions)"

merged_json=$(gh pr view "$pr" --json number,url,state,headRefOid,baseRefName,mergeCommit,comments)
[ "$(printf '%s' "$merged_json" | jq -r '.state')" = "MERGED" ] || fail "forge did not report the PR as merged"
merge_sha=$(printf '%s' "$merged_json" | jq -r '.mergeCommit.oid')
if ! comment_exists "$merged_json"; then
post_comment "$merge_sha"
fi

printf 'MERGED %s @ %s\n' "$pr_url" "$merge_sha"
83 changes: 83 additions & 0 deletions .agentic-workflow/hooks/guard-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash

set -u

command_text=""
file_path=""

while [ "$#" -gt 0 ]; do
case "$1" in
--command)
[ "$#" -ge 2 ] || { echo "agentic-workflow guard: --command needs a value" >&2; exit 2; }
command_text=$2
shift 2
;;
--path)
[ "$#" -ge 2 ] || { echo "agentic-workflow guard: --path needs a value" >&2; exit 2; }
file_path=$2
shift 2
;;
*)
echo "agentic-workflow guard: unknown argument: $1" >&2
exit 2
;;
esac
done

deny() {
printf 'Blocked by agentic-workflow safety policy: %s\n' "$1" >&2
exit 2
}

is_env_path() {
printf '%s\n' "$1" | grep -Eqi '(^|/)(\.env($|\.)|[^/]*\.env($|\.))'
}

if [ -n "$file_path" ] && is_env_path "$file_path"; then
deny "reading environment files may disclose secrets"
fi

if [ -z "$command_text" ]; then
exit 0
fi

# Direct merges stay blocked. Automated merges use fullauto-merge.sh, whose
# child process is outside the agent tool boundary and has its own fail-closed
# checks. There is intentionally no persistent allow marker for these patterns.
# The literal-token match is a deliberate boundary: variable-indirection
# (`cmd=gh; $cmd pr merge`) is out of scope by design, so do not rely on this
# guard as a sandbox — the forge branch rule / CI is the enforcement boundary.
if printf '%s\n' "$command_text" | grep -Eqi '(^|[^[:alnum:]_/-])([^[:space:];&|()[:space:]]*/)?gh([[:space:]]+[^;&|()[:space:]]+){0,8}[[:space:]]+pr[[:space:]]+merge([^[:alnum:]_-]|$)'; then
deny "direct pull-request merge; use ship-roadmap --fullauto"
fi
if printf '%s\n' "$command_text" | grep -Eqi '(^|[^[:alnum:]_/-])glab([[:space:]]+[^;&|()[:space:]]+){0,8}[[:space:]]+mr[[:space:]]+merge([^[:alnum:]_-]|$)'; then
deny "direct merge-request merge; use ship-roadmap --fullauto"
fi
if printf '%s\n' "$command_text" | grep -Eqi '(^|[^[:alnum:]_/-])([^[:space:];&|()[:space:]]*/)?git([[:space:]]+[^;&|()[:space:]]+){0,8}[[:space:]]+merge([^[:alnum:]_-]|$)'; then
deny "direct git merge"
fi
if printf '%s\n' "$command_text" | grep -Eqi 'mergePullRequest|/pulls/[0-9]+/merge([?[:space:]]|$)'; then
deny "merge through a forge API"
fi

# Block disclosure commands, not legitimate assignments such as
# `export NODE_ENV=test` or `env NODE_ENV=test command`.
if printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])([^;&|()[:space:]]*/)?printenv([[:space:]]|$)' \
|| printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])([^;&|()[:space:]]*/)?(declare|typeset)[[:space:]]+-x([[:space:]]|$)'; then
deny "environment-variable disclosure"
fi
if printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])export([[:space:]]+-p)?[[:space:]]*($|[;&|)])'; then
deny "environment export listing"
fi
if printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])([^;&|()[:space:]]*/)?env([[:space:]]+(-0|--null))?[[:space:]]*($|[;&|)])' \
|| printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])([^;&|()[:space:]]*/)?env([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+)+[[:space:]]*($|[;&|)])' \
|| printf '%s\n' "$command_text" | grep -Eqi '(^|[;&|()[:space:]])([^;&|()[:space:]]*/)?set[[:space:]]*($|[;&|)])'; then
deny "environment-variable disclosure"
fi

# Shell reads are covered even on platforms that expose only a shell hook.
if printf '%s\n' "$command_text" | grep -Eqi '(^|[^[:alnum:]_])\.env([[:alnum:]_.-]*)([^[:alnum:]_]|$)'; then
deny "reading environment files may disclose secrets"
fi

exit 0
Loading
Loading