diff --git a/.agentic-workflow/hooks/README.md b/.agentic-workflow/hooks/README.md new file mode 100644 index 00000000..4c83b4bb --- /dev/null +++ b/.agentic-workflow/hooks/README.md @@ -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. diff --git a/.agentic-workflow/hooks/adapters/copilot-guard.sh b/.agentic-workflow/hooks/adapters/copilot-guard.sh new file mode 100755 index 00000000..b985b03f --- /dev/null +++ b/.agentic-workflow/hooks/adapters/copilot-guard.sh @@ -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 diff --git a/.agentic-workflow/hooks/adapters/normalize-hook-payload.sh b/.agentic-workflow/hooks/adapters/normalize-hook-payload.sh new file mode 100755 index 00000000..6c11f972 --- /dev/null +++ b/.agentic-workflow/hooks/adapters/normalize-hook-payload.sh @@ -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 diff --git a/.agentic-workflow/hooks/adapters/pre-tool-guard.sh b/.agentic-workflow/hooks/adapters/pre-tool-guard.sh new file mode 100755 index 00000000..2ad25ff9 --- /dev/null +++ b/.agentic-workflow/hooks/adapters/pre-tool-guard.sh @@ -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" diff --git a/.agentic-workflow/hooks/fullauto-merge.sh b/.agentic-workflow/hooks/fullauto-merge.sh new file mode 100755 index 00000000..7756087a --- /dev/null +++ b/.agentic-workflow/hooks/fullauto-merge.sh @@ -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="" +marker="" + +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" diff --git a/.agentic-workflow/hooks/guard-command.sh b/.agentic-workflow/hooks/guard-command.sh new file mode 100755 index 00000000..56801a43 --- /dev/null +++ b/.agentic-workflow/hooks/guard-command.sh @@ -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 diff --git a/.agentic-workflow/hooks/tests/test-command-guard.sh b/.agentic-workflow/hooks/tests/test-command-guard.sh new file mode 100755 index 00000000..41b76392 --- /dev/null +++ b/.agentic-workflow/hooks/tests/test-command-guard.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -u + +test_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +guard=$(CDPATH='' cd -- "$test_dir/.." && pwd)/guard-command.sh +failures=0 + +expect_allow() { + label=$1 + shift + if ! "$guard" "$@" >/dev/null 2>&1; then + printf 'FAIL allow: %s\n' "$label" >&2 + failures=$((failures + 1)) + fi +} + +expect_block() { + label=$1 + shift + if "$guard" "$@" >/dev/null 2>&1; then + printf 'FAIL block: %s\n' "$label" >&2 + failures=$((failures + 1)) + fi +} + +expect_allow "export assignment" --command "export NODE_ENV=test" +expect_allow "export existing name" --command "export NODE_ENV" +expect_allow "env-prefixed command" --command "env NODE_ENV=test npm test" +expect_allow "export word in filename" --command "node scripts/export-report.mjs" +expect_allow "merge-base is not merge" --command "git merge-base main HEAD" +expect_allow "fullauto wrapper" --command "bash .agentic-workflow/hooks/fullauto-merge.sh --pr 12" +expect_allow "variable-indirection merge (documented boundary)" --command 'cmd=gh; $cmd pr merge 12' + +expect_block "export listing" --command "export" +expect_block "export -p" --command "export -p" +expect_block "env listing" --command "env" +expect_block "absolute env listing" --command "/usr/bin/env" +expect_block "env assignment-only listing" --command "env NODE_ENV=test" +expect_block "printenv name" --command "printenv API_KEY" +expect_block "absolute printenv name" --command "/usr/bin/printenv API_KEY" +expect_block "declare exports" --command "declare -x" +expect_block "direct PR merge" --command "gh pr merge 12 --squash" +expect_block "bash-wrapped PR merge" --command "bash -c 'gh pr merge 12 --squash'" +expect_block "python-wrapped PR merge" --command "python -c 'os.system(\"gh pr merge 12\")'" +expect_block "shell-wrapped git merge" --command "sh -c 'git merge feature'" +expect_block "absolute PR merge" --command "/usr/bin/gh pr merge 12 --squash" +expect_block "repo-option PR merge" --command "gh --repo acme/app pr merge 12" +expect_block "attached repo-option PR merge" --command "gh --repo=acme/app pr merge 12" +expect_block "attached short repo-option PR merge" --command "gh -Racme/app pr merge 12" +expect_block "direct MR merge" --command "glab mr merge 12" +expect_block "repo-option MR merge" --command "glab -R acme/app mr merge 12" +expect_block "attached repo-option MR merge" --command "glab -Racme/app mr merge 12" +expect_block "direct git merge" --command "git merge feature" +expect_block "git -C merge" --command "git -C /tmp/repo merge feature" +expect_block "attached git -C merge" --command "git -C/tmp/repo merge feature" +expect_block "REST merge" --command "gh api -X PUT repos/acme/app/pulls/12/merge" +expect_block "GraphQL merge" --command "gh api graphql -f query='mutation { mergePullRequest }'" +expect_block "shell env read" --command "cat .env" +expect_block "grep env read" --command "grep API_KEY .env" +expect_block "sed env read" --command "sed -n 1p .env" +expect_block "awk env read" --command "awk 1 .env" +expect_block "quoted env read" --command "cat '.env'" +expect_block "nested env read" --command "head -1 config/.env.production" +expect_block "copy env read" --command "cp .env /tmp/env.backup" +expect_block "python env read" --command "python -c 'open(\".env\").read()'" +expect_block "read-tool env path" --path "/srv/app/.env.local" + +if command -v jq >/dev/null 2>&1; then + adapter=$(CDPATH='' cd -- "$test_dir/../adapters" && pwd)/pre-tool-guard.sh + copilot=$(CDPATH='' cd -- "$test_dir/../adapters" && pwd)/copilot-guard.sh + if printf '%s' '{"tool_input":{"command":"gh pr merge 12"}}' | "$adapter" >/dev/null 2>&1; then + printf 'FAIL block: normalized Claude/Cursor adapter\n' >&2 + failures=$((failures + 1)) + fi + copilot_output=$(printf '%s' '{"tool_input":{"file_path":".env"}}' | "$copilot") + printf '%s' "$copilot_output" | jq -e '.continue == false and (.stopReason | contains("Blocked"))' >/dev/null || { + printf 'FAIL block: Copilot adapter\n' >&2 + failures=$((failures + 1)) + } + if printf '%s' '{not-json' | "$adapter" >/dev/null 2>&1; then + printf 'FAIL block: malformed Claude/Cursor payload\n' >&2 + failures=$((failures + 1)) + fi + if printf '%s' '{}' | "$adapter" >/dev/null 2>&1; then + printf 'FAIL block: unrecognized Claude/Cursor payload\n' >&2 + failures=$((failures + 1)) + fi + newline_payload='{"tool_input":{"command":"echo safe\ngh pr merge 12"}}' + if printf '%s' "$newline_payload" | "$adapter" >/dev/null 2>&1; then + printf 'FAIL block: newline merge payload\n' >&2 + failures=$((failures + 1)) + fi + copilot_output=$(printf '%s' '{not-json' | "$copilot") + printf '%s' "$copilot_output" | jq -e '.continue == false and (.stopReason | contains("invalid hook payload"))' >/dev/null || { + printf 'FAIL block: malformed Copilot payload\n' >&2 + failures=$((failures + 1)) + } +fi + +[ "$failures" -eq 0 ] || exit 1 +printf 'PASS command guard: 7 allowed, 27 blocked, adapters normalized\n' diff --git a/.agentic-workflow/hooks/tests/test-fullauto-merge.sh b/.agentic-workflow/hooks/tests/test-fullauto-merge.sh new file mode 100755 index 00000000..2976efd8 --- /dev/null +++ b/.agentic-workflow/hooks/tests/test-fullauto-merge.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +set -euo pipefail + +hooks_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +wrapper="$hooks_dir/fullauto-merge.sh" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agentic-workflow-fullauto.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +mkdir -p "$fixture/bin" "$fixture/repo/docs/features" "$fixture/state" +git -C "$fixture" init -q --bare remote.git +git -C "$fixture/repo" init -q -b main +git -C "$fixture/repo" config user.name fixture +git -C "$fixture/repo" config user.email fixture@example.invalid +printf 'fixture\n' > "$fixture/repo/README.md" +printf 'merge: fullauto\n' > "$fixture/repo/docs/features/SHIP_DECISIONS.md" +git -C "$fixture/repo" add README.md docs/features/SHIP_DECISIONS.md +git -C "$fixture/repo" commit -qm fixture +git -C "$fixture/repo" remote add origin "$fixture/remote.git" +git -C "$fixture/repo" push -qu origin main +git -C "$fixture/repo" switch -qc feat/fixture +printf 'feature\n' >> "$fixture/repo/README.md" +git -C "$fixture/repo" commit -qam feature +git -C "$fixture/repo" push -qu origin feat/fixture +head_sha=$(git -C "$fixture/repo" rev-parse HEAD) +printf 'OPEN\n' > "$fixture/state/pr-state" +printf '0\n' > "$fixture/state/comments" +printf '0\n' > "$fixture/state/merges" +printf '\n' > "$fixture/state/method" +printf 'main\n' > "$fixture/state/base" +printf 'SUCCESS\n' > "$fixture/state/checks" + +sed "s/__HEAD__/$head_sha/g" > "$fixture/bin/gh" <<'FIXTURE' +#!/usr/bin/env bash +set -euo pipefail +state_dir=${GH_TEST_STATE:?} +if [ "$1 $2" = "pr view" ]; then + state=$(cat "$state_dir/pr-state") + comments=$(cat "$state_dir/comments") + checks=$(cat "$state_dir/checks") + base=$(cat "$state_dir/base") + bodies='[]' + if [ "$comments" -gt 0 ]; then + bodies='[{"body":""}]' + fi + if [ "${GH_TEST_AUDIT:-1}" = "stale" ]; then + bodies='[{"body":""}]' + elif [ "${GH_TEST_AUDIT:-1}" = "1" ]; then + if [ "$comments" -gt 0 ]; then + bodies='[{"body":""},{"body":""}]' + else + bodies='[{"body":""}]' + fi + fi + status='[]' + if [ "$checks" != "NONE" ]; then + status="[{\"conclusion\":\"$checks\"}]" + fi + if printf '%s\n' "$*" | grep -q 'mergeCommit'; then + printf '{"number":12,"url":"https://example.invalid/pr/12","state":"%s","baseRefName":"%s","headRefOid":"__HEAD__","mergeable":"MERGEABLE","statusCheckRollup":%s,"comments":%s,"mergeCommit":{"oid":"abc1234"}}\n' "$state" "$base" "$status" "$bodies" + else + printf '{"number":12,"url":"https://example.invalid/pr/12","state":"%s","baseRefName":"%s","headRefOid":"__HEAD__","mergeable":"MERGEABLE","statusCheckRollup":%s,"comments":%s}\n' "$state" "$base" "$status" "$bodies" + fi + exit 0 +fi +if [ "$1 $2" = "repo view" ]; then + printf '{"nameWithOwner":"acme/app","defaultBranchRef":{"name":"main"}}\n' + exit 0 +fi +if [ "$1" = "api" ]; then + printf '{"content":"%s"}\n' "$(printf 'merge: fullauto\n' | base64 | tr -d '\n')" + exit 0 +fi +if [ "$1 $2" = "pr merge" ]; then + [ "${GH_TEST_FAIL_MERGE:-0}" = "0" ] || exit 1 + printf '%s\n' "$*" > "$state_dir/method" + printf 'MERGED\n' > "$state_dir/pr-state" + printf '%s\n' "$(( $(cat "$state_dir/merges") + 1 ))" > "$state_dir/merges" + exit 0 +fi +if [ "$1 $2" = "pr comment" ]; then + count=$(cat "$state_dir/comments") + printf '%s\n' "$((count + 1))" > "$state_dir/comments" + exit 0 +fi +printf 'unsupported fake gh call: %s\n' "$*" >&2 +exit 2 +FIXTURE +chmod +x "$fixture/bin/gh" + +run_wrapper() { + (cd "$fixture/repo" && PATH="$fixture/bin:$PATH" GH_TEST_STATE="$fixture/state" \ + "$wrapper" --pr 12 --run-id fixture-run) +} + +missing_value=$({ "$wrapper" --pr; } 2>&1 || true) +printf '%s' "$missing_value" | grep -q -- '--pr requires a value' + +run_wrapper >/dev/null +[ "$(cat "$fixture/state/comments")" = "1" ] +[ "$(cat "$fixture/state/merges")" = "1" ] +grep -q -- '--merge' "$fixture/state/method" +if find "$fixture/repo/.git/agentic-workflow" -type f -name 'automerge-*' 2>/dev/null | grep -q .; then + echo "FAIL: attempt marker survived successful merge" >&2 + exit 1 +fi + +# A retry reconciles the already-merged PR and does not duplicate its comment. +run_wrapper >/dev/null +[ "$(cat "$fixture/state/comments")" = "1" ] +[ "$(cat "$fixture/state/merges")" = "1" ] + +for case_name in unauthorized-audit stale-audit foreign-base failed-ci stale-local-gate; do + printf 'OPEN\n' > "$fixture/state/pr-state" + printf '0\n' > "$fixture/state/merges" + case "$case_name" in + unauthorized-audit) GH_TEST_AUDIT=0 run_wrapper >/dev/null 2>&1 && exit 1 || true ;; + stale-audit) GH_TEST_AUDIT=stale run_wrapper >/dev/null 2>&1 && exit 1 || true ;; + foreign-base) printf 'develop\n' > "$fixture/state/base"; run_wrapper >/dev/null 2>&1 && exit 1 || true; printf 'main\n' > "$fixture/state/base" ;; + failed-ci) printf 'FAILURE\n' > "$fixture/state/checks"; run_wrapper >/dev/null 2>&1 && exit 1 || true; printf 'SUCCESS\n' > "$fixture/state/checks" ;; + stale-local-gate) printf 'NONE\n' > "$fixture/state/checks"; AGENTIC_WORKFLOW_LOCAL_GATE_SHA=deadbeef run_wrapper >/dev/null 2>&1 && exit 1 || true; printf 'SUCCESS\n' > "$fixture/state/checks" ;; + esac + [ "$(cat "$fixture/state/merges")" = "0" ] +done + +# The check_count=0 branch must still merge when a fresh local gate SHA matches. +printf 'OPEN\n' > "$fixture/state/pr-state" +printf '0\n' > "$fixture/state/merges" +printf 'NONE\n' > "$fixture/state/checks" +AGENTIC_WORKFLOW_LOCAL_GATE_SHA="$head_sha" run_wrapper >/dev/null +[ "$(cat "$fixture/state/merges")" = "1" ] +printf 'SUCCESS\n' > "$fixture/state/checks" + +printf 'OPEN\n' > "$fixture/state/pr-state" +printf '0\n' > "$fixture/state/merges" +if GH_TEST_FAIL_MERGE=1 run_wrapper >/dev/null 2>&1; then + echo "FAIL: merge failure was reported as success" >&2 + exit 1 +fi + +if [ "$(cat "$fixture/state/merges")" != "0" ]; then + echo "FAIL: failed merge invoked fake merge unexpectedly" >&2 + exit 1 +fi +if find "$fixture/repo/.git/agentic-workflow" -type f -name 'automerge-*' 2>/dev/null | grep -q .; then + echo "FAIL: attempt marker survived failed merge" >&2 + exit 1 +fi + +printf 'PASS fullauto merge: transient marker cleaned; PR comment idempotent\n' diff --git a/.agentic-workflow/hooks/tests/test-init-workspace-contract.sh b/.agentic-workflow/hooks/tests/test-init-workspace-contract.sh new file mode 100755 index 00000000..195530d9 --- /dev/null +++ b/.agentic-workflow/hooks/tests/test-init-workspace-contract.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$test_dir/../../../.." && pwd) +template_dir="$repo_root/template" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agentic-workflow-init.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +expected_files=( + ".agentic-workflow/hooks/guard-command.sh" + ".agentic-workflow/hooks/fullauto-merge.sh" + ".agentic-workflow/hooks/adapters/pre-tool-guard.sh" + ".agentic-workflow/hooks/adapters/copilot-guard.sh" + ".agentic-workflow/hooks/adapters/normalize-hook-payload.sh" + ".claude/settings.json.example" + ".cursor/hooks.json.example" + ".github/hooks/agentic-workflow.json.example" + ".opencode/plugins/agentic-workflow-guard.ts.example" +) + +for path in "${expected_files[@]}"; do + [ -f "$template_dir/$path" ] +done + +project="$fixture/project" +mkdir -p "$project/.agentic-workflow/hooks/adapters" "$project/.claude" "$project/.cursor" "$project/.github/hooks" "$project/.opencode/plugins" + +cp "$template_dir/.agentic-workflow/hooks/guard-command.sh" "$project/.agentic-workflow/hooks/guard-command.sh" +cp "$template_dir/.agentic-workflow/hooks/fullauto-merge.sh" "$project/.agentic-workflow/hooks/fullauto-merge.sh" +cp "$template_dir/.agentic-workflow/hooks/adapters/pre-tool-guard.sh" "$project/.agentic-workflow/hooks/adapters/pre-tool-guard.sh" +cp "$template_dir/.agentic-workflow/hooks/adapters/copilot-guard.sh" "$project/.agentic-workflow/hooks/adapters/copilot-guard.sh" +cp "$template_dir/.agentic-workflow/hooks/adapters/normalize-hook-payload.sh" "$project/.agentic-workflow/hooks/adapters/normalize-hook-payload.sh" +cp "$template_dir/.cursor/hooks.json.example" "$project/.cursor/hooks.json.example" +cp "$template_dir/.github/hooks/agentic-workflow.json.example" "$project/.github/hooks/agentic-workflow.json.example" +cp "$template_dir/.opencode/plugins/agentic-workflow-guard.ts.example" "$project/.opencode/plugins/agentic-workflow-guard.ts.example" + +printf 'customized hook configuration\n' > "$project/.claude/settings.json" +copy_if_missing() { + [ -e "$2" ] || cp "$1" "$2" +} +copy_if_missing "$template_dir/.claude/settings.json.example" "$project/.claude/settings.json" + +grep -q "customized hook configuration" "$project/.claude/settings.json" +[ -f "$project/.agentic-workflow/hooks/guard-command.sh" ] +[ -f "$project/.agentic-workflow/hooks/adapters/copilot-guard.sh" ] +grep -q "Agent safety hooks" "$repo_root/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md" +grep -q "existing customized hook file becomes a residual" "$repo_root/skills/init-workspace/references/BOOTSTRAP_WRITE.md" +grep -q "residual reporting" "$repo_root/skills/init-workspace/SKILL.md" +bash "$test_dir/test-opencode-guard.sh" + +printf 'PASS init-workspace contract: scratch install, inventory, additive preservation, residual reporting\n' diff --git a/.agentic-workflow/hooks/tests/test-opencode-guard.sh b/.agentic-workflow/hooks/tests/test-opencode-guard.sh new file mode 100755 index 00000000..278e6283 --- /dev/null +++ b/.agentic-workflow/hooks/tests/test-opencode-guard.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command -v bun >/dev/null 2>&1 || { + printf 'SKIP OpenCode guard: bun is unavailable\n' + exit 0 +} + +test_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$test_dir/../../.." && pwd) +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agentic-workflow-opencode.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +mkdir -p "$fixture/.agentic-workflow/hooks" +cp "$repo_root/.agentic-workflow/hooks/guard-command.sh" "$fixture/.agentic-workflow/hooks/guard-command.sh" +plugin="$fixture/agentic-workflow-guard.ts" +cp "$repo_root/.opencode/plugins/agentic-workflow-guard.ts" "$plugin" + +OPENCODE_PLUGIN="$plugin" OPENCODE_FIXTURE="$fixture" bun -e ' + import { pathToFileURL } from "node:url"; + const plugin = await import(pathToFileURL(process.env.OPENCODE_PLUGIN).href); + const hooks = await plugin.AgenticWorkflowGuard({ worktree: process.env.OPENCODE_FIXTURE }); + const before = hooks["tool.execute.before"]; + const input = { tool: "bash" }; + const blocked = async (command) => { + try { + await before(input, { args: { command } }); + return false; + } catch { + return true; + } + }; + if (!await blocked("gh pr merge 12")) process.exit(1); + if (!await blocked("cat .env")) process.exit(1); + await before(input, { args: { command: "printf safe" } }); +' + +printf 'PASS OpenCode guard: allow and block paths exercised\n' diff --git a/.agents/skills/bash-defensive-patterns/SKILL.md b/.agents/skills/bash-defensive-patterns/SKILL.md new file mode 100644 index 00000000..e3ad6394 --- /dev/null +++ b/.agents/skills/bash-defensive-patterns/SKILL.md @@ -0,0 +1,533 @@ +--- +name: bash-defensive-patterns +description: Master defensive Bash programming techniques for production-grade scripts. Use when writing robust shell scripts, CI/CD pipelines, or system utilities requiring fault tolerance and safety. +--- + +# Bash Defensive Patterns + +Comprehensive guidance for writing production-ready Bash scripts using defensive programming techniques, error handling, and safety best practices to prevent common pitfalls and ensure reliability. + +## When to Use This Skill + +- Writing production automation scripts +- Building CI/CD pipeline scripts +- Creating system administration utilities +- Developing error-resilient deployment automation +- Writing scripts that must handle edge cases safely +- Building maintainable shell script libraries +- Implementing comprehensive logging and monitoring +- Creating scripts that must work across different platforms + +## Core Defensive Principles + +### 1. Strict Mode + +Enable bash strict mode at the start of every script to catch errors early. + +```bash +#!/bin/bash +set -Eeuo pipefail # Exit on error, unset variables, pipe failures +``` + +**Key flags:** + +- `set -E`: Inherit ERR trap in functions +- `set -e`: Exit on any error (command returns non-zero) +- `set -u`: Exit on undefined variable reference +- `set -o pipefail`: Pipe fails if any command fails (not just last) + +### 2. Error Trapping and Cleanup + +Implement proper cleanup on script exit or error. + +```bash +#!/bin/bash +set -Eeuo pipefail + +trap 'echo "Error on line $LINENO"' ERR +trap 'echo "Cleaning up..."; rm -rf "$TMPDIR"' EXIT + +TMPDIR=$(mktemp -d) +# Script code here +``` + +### 3. Variable Safety + +Always quote variables to prevent word splitting and globbing issues. + +```bash +# Wrong - unsafe +cp $source $dest + +# Correct - safe +cp "$source" "$dest" + +# Required variables - fail with message if unset +: "${REQUIRED_VAR:?REQUIRED_VAR is not set}" +``` + +### 4. Array Handling + +Use arrays safely for complex data handling. + +```bash +# Safe array iteration +declare -a items=("item 1" "item 2" "item 3") + +for item in "${items[@]}"; do + echo "Processing: $item" +done + +# Reading output into array safely +mapfile -t lines < <(some_command) +readarray -t numbers < <(seq 1 10) +``` + +### 5. Conditional Safety + +Use `[[ ]]` for Bash-specific features, `[ ]` for POSIX. + +```bash +# Bash - safer +if [[ -f "$file" && -r "$file" ]]; then + content=$(<"$file") +fi + +# POSIX - portable +if [ -f "$file" ] && [ -r "$file" ]; then + content=$(cat "$file") +fi + +# Test for existence before operations +if [[ -z "${VAR:-}" ]]; then + echo "VAR is not set or is empty" +fi +``` + +## Fundamental Patterns + +### Pattern 1: Safe Script Directory Detection + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Correctly determine script directory +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")" + +echo "Script location: $SCRIPT_DIR/$SCRIPT_NAME" +``` + +### Pattern 2: Comprehensive Function Templat + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Prefix for functions: handle_*, process_*, check_*, validate_* +# Include documentation and error handling + +validate_file() { + local -r file="$1" + local -r message="${2:-File not found: $file}" + + if [[ ! -f "$file" ]]; then + echo "ERROR: $message" >&2 + return 1 + fi + return 0 +} + +process_files() { + local -r input_dir="$1" + local -r output_dir="$2" + + # Validate inputs + [[ -d "$input_dir" ]] || { echo "ERROR: input_dir not a directory" >&2; return 1; } + + # Create output directory if needed + mkdir -p "$output_dir" || { echo "ERROR: Cannot create output_dir" >&2; return 1; } + + # Process files safely + while IFS= read -r -d '' file; do + echo "Processing: $file" + # Do work + done < <(find "$input_dir" -maxdepth 1 -type f -print0) + + return 0 +} +``` + +### Pattern 3: Safe Temporary File Handling + +```bash +#!/bin/bash +set -Eeuo pipefail + +trap 'rm -rf -- "$TMPDIR"' EXIT + +# Create temporary directory +TMPDIR=$(mktemp -d) || { echo "ERROR: Failed to create temp directory" >&2; exit 1; } + +# Create temporary files in directory +TMPFILE1="$TMPDIR/temp1.txt" +TMPFILE2="$TMPDIR/temp2.txt" + +# Use temporary files +touch "$TMPFILE1" "$TMPFILE2" + +echo "Temp files created in: $TMPDIR" +``` + +### Pattern 4: Robust Argument Parsing + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Default values +VERBOSE=false +DRY_RUN=false +OUTPUT_FILE="" +THREADS=4 + +usage() { + cat <&2 + usage 1 + ;; + esac +done + +# Validate required arguments +[[ -n "$OUTPUT_FILE" ]] || { echo "ERROR: -o/--output is required" >&2; usage 1; } +``` + +### Pattern 5: Structured Logging + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Logging functions +log_info() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" >&2 +} + +log_warn() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*" >&2 +} + +log_error() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 +} + +log_debug() { + if [[ "${DEBUG:-0}" == "1" ]]; then + echo "[$(date +'%Y-%m-%d %H:%M:%S')] DEBUG: $*" >&2 + fi +} + +# Usage +log_info "Starting script" +log_debug "Debug information" +log_warn "Warning message" +log_error "Error occurred" +``` + +### Pattern 6: Process Orchestration with Signals + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Track background processes +PIDS=() + +cleanup() { + log_info "Shutting down..." + + # Terminate all background processes + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + fi + done + + # Wait for graceful shutdown + for pid in "${PIDS[@]}"; do + wait "$pid" 2>/dev/null || true + done +} + +trap cleanup SIGTERM SIGINT + +# Start background tasks +background_task & +PIDS+=($!) + +another_task & +PIDS+=($!) + +# Wait for all background processes +wait +``` + +### Pattern 7: Safe File Operations + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Use -i flag to move safely without overwriting +safe_move() { + local -r source="$1" + local -r dest="$2" + + if [[ ! -e "$source" ]]; then + echo "ERROR: Source does not exist: $source" >&2 + return 1 + fi + + if [[ -e "$dest" ]]; then + echo "ERROR: Destination already exists: $dest" >&2 + return 1 + fi + + mv "$source" "$dest" +} + +# Safe directory cleanup +safe_rmdir() { + local -r dir="$1" + + if [[ ! -d "$dir" ]]; then + echo "ERROR: Not a directory: $dir" >&2 + return 1 + fi + + # Use -I flag to prompt before rm (BSD/GNU compatible) + rm -rI -- "$dir" +} + +# Atomic file writes +atomic_write() { + local -r target="$1" + local -r tmpfile + tmpfile=$(mktemp) || return 1 + + # Write to temp file first + cat > "$tmpfile" + + # Atomic rename + mv "$tmpfile" "$target" +} +``` + +### Pattern 8: Idempotent Script Design + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Check if resource already exists +ensure_directory() { + local -r dir="$1" + + if [[ -d "$dir" ]]; then + log_info "Directory already exists: $dir" + return 0 + fi + + mkdir -p "$dir" || { + log_error "Failed to create directory: $dir" + return 1 + } + + log_info "Created directory: $dir" +} + +# Ensure configuration state +ensure_config() { + local -r config_file="$1" + local -r default_value="$2" + + if [[ ! -f "$config_file" ]]; then + echo "$default_value" > "$config_file" + log_info "Created config: $config_file" + fi +} + +# Rerunning script multiple times should be safe +ensure_directory "/var/cache/myapp" +ensure_config "/etc/myapp/config" "DEBUG=false" +``` + +### Pattern 9: Safe Command Substitution + +```bash +#!/bin/bash +set -Eeuo pipefail + +# Use $() instead of backticks +name=$(<"$file") # Modern, safe variable assignment from file +output=$(command -v python3) # Get command location safely + +# Handle command substitution with error checking +result=$(command -v node) || { + log_error "node command not found" + return 1 +} + +# For multiple lines +mapfile -t lines < <(grep "pattern" "$file") + +# NUL-safe iteration +while IFS= read -r -d '' file; do + echo "Processing: $file" +done < <(find /path -type f -print0) +``` + +### Pattern 10: Dry-Run Support + +```bash +#!/bin/bash +set -Eeuo pipefail + +DRY_RUN="${DRY_RUN:-false}" + +run_cmd() { + if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY RUN] Would execute: $*" + return 0 + fi + + "$@" +} + +# Usage +run_cmd cp "$source" "$dest" +run_cmd rm "$file" +run_cmd chown "$owner" "$target" +``` + +## Advanced Defensive Techniques + +### Named Parameters Pattern + +```bash +#!/bin/bash +set -Eeuo pipefail + +process_data() { + local input_file="" + local output_dir="" + local format="json" + + # Parse named parameters + while [[ $# -gt 0 ]]; do + case "$1" in + --input=*) + input_file="${1#*=}" + ;; + --output=*) + output_dir="${1#*=}" + ;; + --format=*) + format="${1#*=}" + ;; + *) + echo "ERROR: Unknown parameter: $1" >&2 + return 1 + ;; + esac + shift + done + + # Validate required parameters + [[ -n "$input_file" ]] || { echo "ERROR: --input is required" >&2; return 1; } + [[ -n "$output_dir" ]] || { echo "ERROR: --output is required" >&2; return 1; } +} +``` + +### Dependency Checking + +```bash +#!/bin/bash +set -Eeuo pipefail + +check_dependencies() { + local -a missing_deps=() + local -a required=("jq" "curl" "git") + + for cmd in "${required[@]}"; do + if ! command -v "$cmd" &>/dev/null; then + missing_deps+=("$cmd") + fi + done + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + echo "ERROR: Missing required commands: ${missing_deps[*]}" >&2 + return 1 + fi +} + +check_dependencies +``` + +## Best Practices Summary + +1. **Always use strict mode** - `set -Eeuo pipefail` +2. **Quote all variables** - `"$variable"` prevents word splitting +3. **Use [[]] conditionals** - More robust than [ ] +4. **Implement error trapping** - Catch and handle errors gracefully +5. **Validate all inputs** - Check file existence, permissions, formats +6. **Use functions for reusability** - Prefix with meaningful names +7. **Implement structured logging** - Include timestamps and levels +8. **Support dry-run mode** - Allow users to preview changes +9. **Handle temporary files safely** - Use mktemp, cleanup with trap +10. **Design for idempotency** - Scripts should be safe to rerun +11. **Document requirements** - List dependencies and minimum versions +12. **Test error paths** - Ensure error handling works correctly +13. **Use `command -v`** - Safer than `which` for checking executables +14. **Prefer printf over echo** - More predictable across systems diff --git a/.agents/skills/bun/SKILL.md b/.agents/skills/bun/SKILL.md new file mode 100644 index 00000000..c2874879 --- /dev/null +++ b/.agents/skills/bun/SKILL.md @@ -0,0 +1,231 @@ +--- +name: bun +description: Use when building, testing, and deploying JavaScript/TypeScript applications. Reach for Bun when you need to run scripts, manage dependencies, bundle code, or test applications with a single unified tool. +metadata: + mintlify-proj: bun + version: "1.0" +--- + +# Bun Skill Reference + +## Product Summary + +Bun is a unified JavaScript runtime, package manager, bundler, and test runner written in Zig. It replaces Node.js, npm, esbuild, and Jest with a single fast binary. Key files: `bunfig.toml` (configuration), `bun.lock` (lockfile), `package.json` (project metadata). Primary commands: `bun run`, `bun install`, `bun build`, `bun test`. Bun is 4x faster than Node.js on startup and 25x faster than npm for installations. Visit https://bun.com/docs for comprehensive documentation. + +## When to Use + +Use Bun when: +- **Running scripts**: Execute TypeScript/JavaScript files directly without compilation steps (`bun run file.ts`) +- **Managing dependencies**: Install, add, remove, or update packages faster than npm/yarn/pnpm (`bun install`, `bun add`) +- **Bundling code**: Build JavaScript/TypeScript for browser or server targets with `bun build` +- **Testing**: Run Jest-compatible tests with built-in test runner (`bun test`) +- **Building full-stack apps**: Bundle server and client code together into single executables +- **Monorepo workflows**: Use workspaces and filtering to manage multiple packages +- **Replacing Node.js**: Run any Node.js-compatible code with better performance + +Do not use Bun for: type checking (use `tsc` separately), generating type declarations, or projects requiring exact Node.js compatibility for native modules. + +## Quick Reference + +### Essential Commands + +| Task | Command | Notes | +|------|---------|-------| +| Run TypeScript file | `bun run file.ts` | Transpiles on-the-fly; omit `run` for short form | +| Run package script | `bun run dev` | Executes script from `package.json` | +| Install dependencies | `bun install` | Creates `bun.lock` lockfile | +| Add package | `bun add react` | Adds to `dependencies`; use `-d` for dev | +| Remove package | `bun remove react` | Removes from `package.json` and `node_modules` | +| Run tests | `bun test` | Finds `*.test.ts`, `*.spec.ts` files automatically | +| Build bundle | `bun build ./src/index.ts --outdir ./dist` | Bundles with tree-shaking, minification optional | +| Watch mode | `bun --watch run file.ts` | Re-runs on file changes | +| Create project | `bun init` | Scaffolds new project with templates | + +### Configuration File: bunfig.toml + +Located at project root or `~/.bunfig.toml` (global). Optional but useful for customization. + +```toml +[install] +dev = true # Install devDependencies +optional = true # Install optionalDependencies +peer = true # Install peerDependencies +linker = "hoisted" # "hoisted" or "isolated" (pnpm-style) +saveTextLockfile = true # Use text bun.lock instead of binary + +[serve] +port = 3000 # Default port for Bun.serve() + +[test] +root = "." # Test root directory +coverage = false # Enable coverage reporting +timeout = 5000 # Per-test timeout in ms +preload = ["./setup.ts"] # Scripts to run before tests + +[run] +shell = "system" # "system" or "bun" (Windows defaults to "bun") +bun = true # Auto-alias node to bun in scripts +``` + +### File Types Supported + +Bun natively handles: `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.toml`, `.yaml`, `.html`, `.css`, `.wasm`, `.node`. No configuration needed—just import and use. + +### Key Bun APIs + +| API | Purpose | Example | +|-----|---------|---------| +| `Bun.serve()` | Start HTTP server | `Bun.serve({ port: 3000, fetch: handler })` | +| `Bun.file()` | Read/write files | `await Bun.file("path.txt").text()` | +| `Bun.write()` | Write to disk | `await Bun.write("out.txt", data)` | +| `Bun.build()` | Bundle code | `await Bun.build({ entrypoints, outdir })` | +| `Bun.Transpiler` | Transpile code | `new Bun.Transpiler({ loader: "tsx" })` | +| `Bun.spawn()` | Run child process | `Bun.spawn(["ls", "-la"])` | + +## Decision Guidance + +### When to Use Hoisted vs Isolated Linker + +| Scenario | Use | Reason | +|----------|-----|--------| +| New monorepo/workspaces | `isolated` | Prevents phantom dependencies, stricter isolation | +| New single-package project | `hoisted` | Traditional npm behavior, simpler | +| Existing project (pre-v1.3.2) | `hoisted` | Backward compatibility | +| Migrating from pnpm | `isolated` | Matches pnpm's approach | + +Set in `bunfig.toml`: `linker = "isolated"` or via CLI: `bun install --linker isolated` + +### When to Use bun build vs bun run + +| Use Case | Tool | Why | +|----------|------|-----| +| Execute TypeScript directly | `bun run` | Fast transpilation, no output files | +| Prepare for production | `bun build` | Minification, tree-shaking, bundling | +| Ship single executable | `bun build --compile` | Creates standalone binary | +| Development server | `bun run` + `Bun.serve()` | Hot reload, fast iteration | + +### When to Use --concurrent in Tests + +| Scenario | Use `--concurrent` | Reason | +|----------|-------------------|--------| +| Independent async tests | Yes | Parallel execution speeds up suite | +| Tests with shared state | No | Use `test.serial()` for order-dependent tests | +| Database/API tests | Maybe | Only if tests don't interfere | +| Unit tests | Yes | Usually safe and faster | + +## Workflow + +### 1. Initialize a Project +```bash +bun init my-app +cd my-app +``` +Choose template: Blank, React, or Library. Creates `package.json`, `tsconfig.json`, `.gitignore`. + +### 2. Install Dependencies +```bash +bun install +``` +Reads `package.json`, downloads packages, creates `bun.lock`. Much faster than npm. + +### 3. Add Packages +```bash +bun add react +bun add -d @types/react typescript +``` +Updates `package.json` and `bun.lock` automatically. + +### 4. Write and Run Code +```bash +# Create index.ts +echo "console.log('Hello Bun!')" > index.ts + +# Run it +bun run index.ts +``` +Bun transpiles TypeScript on-the-fly; no build step needed. + +### 5. Create HTTP Server +```typescript +// server.ts +const server = Bun.serve({ + port: 3000, + fetch(req) { + return new Response("Hello!"); + }, +}); +console.log(`Listening on ${server.url}`); +``` +```bash +bun run server.ts +``` + +### 6. Write Tests +```typescript +// math.test.ts +import { test, expect } from "bun:test"; + +test("2 + 2 = 4", () => { + expect(2 + 2).toBe(4); +}); +``` +```bash +bun test +``` +Finds and runs all `*.test.ts` files automatically. + +### 7. Bundle for Production +```bash +bun build ./src/index.ts --outdir ./dist --minify +``` +Outputs optimized bundle to `dist/`. Use `--target browser|node|bun` to control output format. + +### 8. Create Standalone Executable +```bash +bun build ./cli.ts --outfile mycli --compile +./mycli +``` +Bundles code + Bun runtime into single executable; no dependencies needed. + +## Common Gotchas + +- **Lifecycle scripts disabled by default**: Bun doesn't run `postinstall` scripts for security. Add trusted packages to `trustedDependencies` in `package.json` to allow them. +- **`bun run` vs `bun