diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml new file mode 100644 index 00000000..28a14453 --- /dev/null +++ b/.github/workflows/installer.yml @@ -0,0 +1,56 @@ +name: Installer + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Installer (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install ShellCheck (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Install ShellCheck (macOS) + if: runner.os == 'macOS' + run: brew install shellcheck + + - name: ShellCheck installer scripts + run: | + shellcheck -x \ + scripts/installer-common.sh \ + scripts/project-install.sh \ + scripts/doctor.sh \ + scripts/uninstall.sh \ + tests/installer.sh + + - name: Show test interpreter + run: | + if [ "${{ runner.os }}" = "macOS" ]; then + /bin/bash --version | head -n 1 + else + bash --version | head -n 1 + fi + + - name: Run installer tests (Linux) + if: runner.os == 'Linux' + run: bash tests/installer.sh + + - name: Run installer tests (macOS system Bash 3.2) + if: runner.os == 'macOS' + run: /bin/bash tests/installer.sh diff --git a/docs/installer.md b/docs/installer.md new file mode 100644 index 00000000..867c5205 --- /dev/null +++ b/docs/installer.md @@ -0,0 +1,213 @@ +# Agent OS installer + +The hardened installer family is made of three small, dependency-free Bash +scripts plus one shared helper: + +| Script | Purpose | +| --- | --- | +| `scripts/project-install.sh` | Install/update standards and commands into a project | +| `scripts/doctor.sh` | Verify a project against its install manifest | +| `scripts/uninstall.sh` | Remove only the files the installer owns | +| `scripts/installer-common.sh` | Shared manifest, hashing, path and profile helpers | + +Every script uses `set -Eeuo pipefail` and begins with `cd/pwd -P` +canonicalisation (never `realpath`), so they run on macOS Bash 3.2 and Linux +Bash 4/5. SHA-256 comes from `sha256sum`, `shasum -a 256` or `openssl`, +whichever is present. + +## Install + +```sh +scripts/project-install.sh [options] +``` + +| Option | Meaning | +| --- | --- | +| `--project-dir ` | Project to install into (default: current directory) | +| `--profile ` | Profile to install (default: `default_profile` in `config.yml`) | +| `--target ` | `claude` installs `.claude/commands/agent-os`, `none` installs standards only (default: `claude`) | +| `--commands-only` | Update commands only; leave existing standards untouched | +| `--dry-run` | Print the plan without changing the project | +| `--yes` | Assume yes for any confirmation prompt | +| `--force` | Overwrite unmanaged/modified files, backing them up first | +| `--verbose` | Show detailed progress (accepted for upstream compatibility) | +| `-h`, `--help` | Usage | + +Unknown options, missing option values, a `--target` other than +`claude`/`none`, profile names containing path separators, and the no-op +combination `--commands-only --target none` are rejected. Installing into the +base installation directory itself is refused. + +### What gets installed + +* Standards: every `*.md` file under `profiles//` is copied to + `agent-os/standards/` at the same relative path. `profiles/default/global/tech-stack.md` + therefore lands at `agent-os/standards/global/tech-stack.md`. +* Index: if the profile supplies `profiles//index.yml` it is **opaque** and + copied **byte-for-byte** (structured metadata included, never parsed or + rewritten). Otherwise a nested-path-aware index is generated in the format the + existing `/index-standards` and `/inject-standards` commands read. On update, + the project's own `agent-os/standards/index.yml` is read back so existing + descriptions carry across the regenerated index: for each `folder/name` key + (folder keys are flat, e.g. `api/auth`) the `description` value is decoded and + reused. The reader understands the simple scalar styles the installer itself + emits — plain scalars, single-quoted strings (`''` for an apostrophe) and + double-quoted strings (`\"` and `\\` escapes) — with two-space name and + four-space description indentation, so a description containing `:`, `#`, + quotes or backslashes is preserved and `#`/`:` are treated as a comment or + separator only **outside** quotes (a literal `hash#name` stays data). It is + deliberately not a general YAML parser and never sources, imports or evaluates + the file: any other structure, an unsupported escape or malformed quoting, a + duplicate folder or name key, a name left without a description before the + next key or the end of the file, a plain key or value that begins with a YAML + indicator (a flow collection `[` `]` `{` `}` `,`, a node tag, anchor or alias + `!` `&` `*`, a block scalar `|` `>`, or another reserved character) or a + literal tab or carriage return inside a quoted scalar is a hard error **before + any project mutation**, so a project index the installer cannot fully + understand is never silently reset back to default descriptions. A CRLF file + is accepted, because only a single trailing carriage return is stripped from + each line. + Descriptions are not carried across profiles, and a supplied profile index is + never consulted for them. Regenerated keys and decoded descriptions are + re-quoted (via the same safe scalar emitter) when they contain + YAML-significant characters such as `:` or `#`. +* Commands: for `--target claude`, `commands/agent-os/*.md` is copied to + `.claude/commands/agent-os/`. + +The installer deliberately does not invent any other structure: no +`agent-os-*` skills, no optimizer, router or adapters. + +### Profiles and inheritance + +Inheritance is read from the `profiles:` section of `config.yml` +(`inherits_from`), never by sourcing profile content. The chain is applied +base-first so a child profile overrides its parents. Invalid parent names, +missing profiles, symlinked profile directories and inheritance cycles are +rejected before anything is written. + +## The manifest + +All writes are recorded in `agent-os/install-manifest.tsv`, a versioned, +tab-separated `hashpath` file: + +``` +# agent-os install manifest v1 +b2d7847...f6960f6 agent-os/standards/global/tech-stack.md +334ff2a...f5cb6e5 .claude/commands/agent-os/inject-standards.md +``` + +Only paths under two owned prefixes may appear: `agent-os/standards/` and +`.claude/commands/agent-os/`. Rows with the wrong field count, a non-hex hash, +a tab, newline or carriage return in the path, a `..`/`.`/empty path segment, a +path outside the prefixes, a missing header, an empty manifest, or duplicate +rows are rejected. A manifest must also be **newline-terminated**: because the +row readers stop at an unterminated final row, such a manifest is rejected +outright rather than being partially trusted (so a truncated manifest can never +make `doctor` report success while ignoring the last file). The manifest lists +files, never directories, and never itself. + +On update, the manifest retains prior entries for scopes a run does not cover, +so a `--commands-only` run keeps the tracked standards (and vice versa). It also +retains rows for files that are still on disk but have since disappeared from +the source profile, so ownership of stale files is never silently dropped and +`doctor`/`uninstall` can still account for them. + +## Safety model + +* **Preflight before writing.** Sources and every destination path component + are checked; symlinked source roots (`profiles`, `commands/agent-os`), + destination symlinks (including a symlinked manifest), non-directory parents + and traversal are refused before any write. +* **No clobbering without consent.** Files that are unmanaged, or tracked but + modified since install, abort the run. `--yes` does not override this — only + `--force` does, and `--force` copies each conflicting file to a freshly + created, symlink-checked `agent-os/.backups/.XXXXXX/` directory + first (unique per run, so repeated or concurrent runs cannot overwrite an + earlier backup). +* **Staged, then committed atomically with rollback.** Content is staged in a + temp directory, preflight runs, then each file is written to a sibling temp + file on the same filesystem and renamed into place, and the manifest is + written last. Each snapshot is validated before the corresponding write is + armed. A failed write, `die`, or `INT`/`TERM`/`HUP` signal restores every + already-committed file, removes files and directories that did not exist + before, and never interpolates untrusted paths into trap code. +* **`--dry-run` never touches the project.** + +## Doctor + +```sh +scripts/doctor.sh [--project-dir ] +``` + +Recomputes the SHA-256 of every manifest-listed file. Missing, modified or +symlinked managed files are reported as drift and the exit status is non-zero +(zero when everything matches). A missing or malformed manifest is an error, as +is a symlinked parent directory anywhere along the manifest or a managed path +(so drift can never hide behind a redirected path component). + +## Uninstall + +```sh +scripts/uninstall.sh [--project-dir ] [--force] +``` + +Before deleting anything, it preflights the manifest and the parents of every +managed path (refusing a symlinked parent), so a redirected path component can +never make it delete files outside the project. It then removes only +manifest-listed files that are still unchanged. Files that were modified since +install are retained, stay tracked in a rewritten manifest, and make the exit +status non-zero. Symlinks are never followed or deleted. Unrelated files are +never touched and directories are never removed recursively. With `--force`, +files that drifted (modified regular files) are copied to a freshly created, +symlink-checked `agent-os/.backups/-uninstall.XXXXXX/` directory and +then removed; symlinks are still never removed. + +## Examples + +```sh +# Default install into the current project +scripts/project-install.sh --yes + +# Preview without changing anything +scripts/project-install.sh --dry-run + +# Standards only, no Claude commands +scripts/project-install.sh --target none --yes + +# Refresh commands after editing a standard by hand +scripts/project-install.sh --commands-only --yes + +# Take over files that were edited or created by hand (keeps a backup) +scripts/project-install.sh --force --yes + +# Check for drift, then clean up +scripts/doctor.sh +scripts/uninstall.sh --force +``` + +## Testing + +`tests/installer.sh` builds throwaway base installs and projects and exercises +flat installs, index preservation/inheritance/quoting, decoded index scalar +round-trip across special keys and quote/backslash/escape styles, fail-closed +rejection of an unsupported project index (leaving the project fingerprint +unchanged), option errors, dry-run, commands-only ownership, unchanged updates, +stale-row retention, unmanaged/drift protection, unique force backups, +source-root/leaf symlink and traversal rejection, manifest integrity (including +rejection of an unterminated or otherwise malformed manifest with no project +mutation), backslash and colon-space filename handling, dotted-profile +inheritance, a failing hash tool aborting before any write, doctor, uninstall +preservation, `--force` drift removal, symlinked-parent deletion and mid-commit +rollback (including created directories whose names contain spaces or a literal +`|`). +Each test runs in an isolated subshell with errexit active, and a harness +self-test fails the suite if a failure followed by a success were ever reported +as a pass: + +```sh +bash tests/installer.sh +``` + +CI (`.github/workflows/installer.yml`) runs the suite and ShellCheck on all +installer scripts on Linux and macOS. On macOS it invokes the suite through the +system `/bin/bash` so the Bash 3.2 code path is exercised. diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 00000000..26a4df17 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Agent OS install doctor. +# +# Verifies that every file recorded in agent-os/install-manifest.tsv still +# exists and matches its recorded SHA-256. Any missing, modified or symlinked +# managed file is reported as drift, and the exit status is non-zero. A +# symlinked parent directory along any managed path is refused outright so drift +# can never be hidden behind a redirected path component. + +set -Eeuo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) + +# shellcheck source=scripts/installer-common.sh +. "$SCRIPT_DIR/installer-common.sh" + +PROGRAM=$(basename "$0") +PROJECT_DIR="" + +show_help() { + cat < Project directory to check (default: current directory) + -h, --help Show this help message +EOF + exit 0 +} + +parse_arguments() { + while [ $# -gt 0 ]; do + case "$1" in + --project-dir) + if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then + ico_die "option --project-dir requires a value" + fi + PROJECT_DIR="$2" + shift 2 + ;; + -h|--help) + show_help + ;; + -*) + ico_die "unknown option: $1" + ;; + *) + ico_die "unexpected argument: $1" + ;; + esac + done +} + +main() { + parse_arguments "$@" + PROJECT_DIR=$(ico_resolve_project_dir "${PROJECT_DIR:-$PWD}") + + local manifest="$PROJECT_DIR/$ICO_MANIFEST_REL" + if [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; then + ico_err "no install manifest found at $ICO_MANIFEST_REL (is Agent OS installed?)" + exit 1 + fi + + # Refuse symlinked parents (and a symlinked manifest) before reading paths. + ico_preflight_managed_paths "$PROJECT_DIR" "$manifest" + + local drift=0 total=0 hash path dest cur + while IFS=$'\t' read -r hash path; do + [ -n "$hash" ] || continue + total=$((total + 1)) + dest="$PROJECT_DIR/$path" + if [ -L "$dest" ]; then + ico_err "drift: $path is a symlink" + drift=$((drift + 1)) + elif [ ! -e "$dest" ]; then + ico_err "drift: $path is missing" + drift=$((drift + 1)) + elif [ ! -f "$dest" ]; then + ico_err "drift: $path is not a regular file" + drift=$((drift + 1)) + else + cur=$(ico_hash_file "$dest") + if [ "$cur" != "$hash" ]; then + ico_err "drift: $path has been modified" + drift=$((drift + 1)) + fi + fi + done < <(ico_manifest_each "$manifest") + + if [ "$drift" -gt 0 ]; then + ico_err "$drift of $total managed file(s) have drifted" + exit 1 + fi + ico_ok "all $total managed file(s) match the manifest" +} + +main "$@" diff --git a/scripts/installer-common.sh b/scripts/installer-common.sh new file mode 100755 index 00000000..f5767703 --- /dev/null +++ b/scripts/installer-common.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +# +# Shared helpers for the hardened Agent OS installer family: +# scripts/project-install.sh, scripts/doctor.sh, scripts/uninstall.sh +# +# Written for portability across macOS Bash 3.2 and Linux Bash 4/5: +# - no associative arrays, no mapfile, no GNU-only tools +# - SHA-256 via sha256sum, shasum -a 256, or openssl +# - project roots are canonicalised with cd/pwd -P (never realpath) + +set -Eeuo pipefail +LC_ALL=C +export LC_ALL + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- + +ICO_MANIFEST_HEADER="# agent-os install manifest v1" +ICO_STANDARDS_PREFIX="agent-os/standards/" +ICO_COMMANDS_PREFIX=".claude/commands/agent-os/" + +# Shared constants consumed by the scripts that source this file. +# shellcheck disable=SC2034 +ICO_MANIFEST_REL="agent-os/install-manifest.tsv" +# shellcheck disable=SC2034 +ICO_DEFAULT_DESCRIPTION="Needs description - run /index-standards" + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +if [ -z "${NO_COLOR:-}" ] && { [ -t 1 ] || [ -t 2 ]; }; then + ICO_RED=$'\033[31m' + ICO_GREEN=$'\033[32m' + ICO_YELLOW=$'\033[33m' + ICO_BLUE=$'\033[34m' + ICO_NC=$'\033[0m' +else + ICO_RED=""; ICO_GREEN=""; ICO_YELLOW=""; ICO_BLUE=""; ICO_NC="" +fi + +ico_info() { printf '%s==>%s %s\n' "$ICO_BLUE" "$ICO_NC" "$*"; } +ico_ok() { printf '%s ok %s %s\n' "$ICO_GREEN" "$ICO_NC" "$*"; } +ico_warn() { printf '%swarn%s %s\n' "$ICO_YELLOW" "$ICO_NC" "$*" >&2; } +ico_err() { printf '%sfail%s %s\n' "$ICO_RED" "$ICO_NC" "$*" >&2; } +ico_die() { ico_err "$*"; exit 1; } + +# --------------------------------------------------------------------------- +# Paths and hashing +# --------------------------------------------------------------------------- + +# Canonicalise an existing directory to an absolute, physically-resolved path. +ico_resolve_project_dir() { + local dir=$1 + if [ ! -d "$dir" ]; then + ico_die "project directory does not exist: $dir" + fi + ( cd "$dir" && pwd -P ) +} + +# Print the SHA-256 of a file as 64 lowercase hex characters. The file is fed +# on stdin (never as a filename argument) so paths containing backslashes, +# newlines or a leading "-" can never be misparsed by the hashing tool. The +# result is validated and any tool failure propagates (never masked into an +# empty or malformed value). +ico_hash_file() { + local file=$1 out + if command -v sha256sum >/dev/null 2>&1; then + out=$(sha256sum <"$file") || return 1 + out=${out%% *} + elif command -v shasum >/dev/null 2>&1; then + out=$(shasum -a 256 <"$file") || return 1 + out=${out%% *} + elif command -v openssl >/dev/null 2>&1; then + out=$(openssl dgst -sha256 <"$file") || return 1 + out=${out##* } + else + ico_die "no SHA-256 tool found (need sha256sum, shasum or openssl)" + fi + ico_is_sha256 "$out" || return 1 + printf '%s\n' "$out" +} + +ico_is_sha256() { + case "$1" in + *[!0-9a-f]*) return 1 ;; + esac + [ "${#1}" -eq 64 ] +} + +# A safe manifest path is relative, free of tabs/newlines/carriage returns and +# free of any empty, "." or ".." segment (so it cannot escape the project). +ico_path_ok() { + local p=$1 + [ -n "$p" ] || return 1 + case "$p" in + /*) return 1 ;; + *$'\t'*|*$'\n'*|*$'\r'*) return 1 ;; + esac + case "/$p/" in + *"/../"*|*"/./"*|*"//"*) return 1 ;; + esac + return 0 +} + +# Only files under these two prefixes are ever owned by the manifest. +ico_path_owned() { + case "$1" in + "$ICO_STANDARDS_PREFIX"*) return 0 ;; + "$ICO_COMMANDS_PREFIX"*) return 0 ;; + esac + return 1 +} + +# Scope of an owned path: "standards" or "commands" (empty when unowned). +ico_scope_of() { + case "$1" in + "$ICO_STANDARDS_PREFIX"*) printf 'standards\n' ;; + "$ICO_COMMANDS_PREFIX"*) printf 'commands\n' ;; + *) return 1 ;; + esac +} + +# Refuse to read or write through a symlink anywhere along a project-relative +# path, and refuse non-directory parents / non-regular-file destinations. +ico_assert_dest_safe() { + local root=$1 + local cur=$root part rest=$2 + while :; do + case "$rest" in + */*) part=${rest%%/*}; rest=${rest#*/} ;; + *) part=$rest; rest="" ;; + esac + cur="$cur/$part" + if [ -L "$cur" ]; then + ico_die "refusing to use symlink path component: $cur" + fi + if [ -n "$rest" ]; then + if [ -e "$cur" ] && [ ! -d "$cur" ]; then + ico_die "path component is not a directory: $cur" + fi + else + if [ -e "$cur" ] && [ ! -f "$cur" ]; then + ico_die "destination is not a regular file: $cur" + fi + fi + [ -n "$rest" ] || break + done +} + +# Assert that every *parent* directory component of a project-relative path is +# a real directory (never a symlink). The final leaf is intentionally ignored so +# callers can still report leaf-level drift. Dies before any mutation. +ico_assert_parent_safe() { + local root=$1 + local cur=$root part rest=$2 + while :; do + case "$rest" in + */*) part=${rest%%/*}; rest=${rest#*/} ;; + *) break ;; + esac + cur="$cur/$part" + if [ -L "$cur" ]; then + ico_die "refusing to use symlink path component: $cur" + fi + if [ -e "$cur" ] && [ ! -d "$cur" ]; then + ico_die "path component is not a directory: $cur" + fi + done +} + +# --------------------------------------------------------------------------- +# Backups +# --------------------------------------------------------------------------- + +# Create and print a fresh, uniquely named backup directory under +# agent-os/.backups. Refuses symlinked backup components and never reuses a +# name, so concurrent or repeated runs cannot clobber earlier backups. +ico_make_backup_dir() { + local root=$1 label=$2 rel="agent-os/.backups" + ico_assert_parent_safe "$root" "$rel/placeholder" + mkdir -p "$root/$rel" + mktemp -d "$root/$rel/$label.XXXXXX" +} + +# --------------------------------------------------------------------------- +# YAML scalar emission +# --------------------------------------------------------------------------- + +# True when a value can be emitted as an unquoted YAML plain scalar. Anything +# containing a ": ", "#", quote, backslash, comma, bracket, indicator character, +# leading/trailing whitespace or tab is rejected and will be quoted instead. +# So are the reserved YAML words (true/false/yes/no/on/off/null, matched +# case-insensitively) and any value starting with a digit, so filenames such as +# "true.md" or "2024-01-01.md" are never read back as booleans, numbers or +# dates. Ordinary names (global, root, tech-stack) stay plain. +ico_yaml_plain_ok() { + local v=$1 lower + [ -n "$v" ] || return 1 + case "$v" in + [A-Za-z]*) : ;; + *) return 1 ;; + esac + case "$v" in + *[!A-Za-z0-9\ ./_-]*) return 1 ;; + esac + case "$v" in + *' '|*$'\t'*) return 1 ;; + esac + lower=$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]') + case "$lower" in + true|false|yes|no|on|off|null) return 1 ;; + esac + return 0 +} + +# Emit a YAML-safe scalar: a bare word when unambiguously safe, otherwise a +# double-quoted, escaped string (so filenames with ":" or "#" stay valid). +ico_yaml_scalar() { + local v=$1 + if ico_yaml_plain_ok "$v"; then + printf '%s' "$v" + return 0 + fi + v=${v//\\/\\\\} + v=${v//\"/\\\"} + printf '"%s"' "$v" +} + +# --------------------------------------------------------------------------- +# Profiles and inheritance (read from config.yml, never sourced) +# --------------------------------------------------------------------------- + +ico_profile_name_ok() { + local n=$1 + [ -n "$n" ] || return 1 + case "$n" in + .|..|-*|*/*|*\\*|*$'\t'*|*$'\n'*) return 1 ;; + esac + case "$n" in + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +ico_config_default_profile() { + local file=$1 value + value=$(sed -n 's/^default_profile:[[:space:]]*//p' "$file" | head -n 1) + value=$(printf '%s' "$value" | sed 's/[[:space:]]*$//') + if [ -n "$value" ]; then + printf '%s\n' "$value" + else + printf 'default\n' + fi +} + +# Print the inherits_from value for a profile, or nothing when unset. The target +# stanza is selected by an exact string comparison of the parsed key against the +# requested profile name (never by regex interpolation), so a profile such as +# "foo.bar" can never match an unrelated "fooXbar" stanza. +ico_config_inherits_from() { + local file=$1 profile=$2 + awk -v profile="$profile" ' + /^profiles:[[:space:]]*$/ { in_profiles = 1; next } + in_profiles && /^[^[:space:]]/ { in_profiles = 0 } + !in_profiles { next } + !in_target { + line = $0 + if (line ~ /^ [^[:space:]][^:]*:[[:space:]]*$/) { + key = line + sub(/^ /, "", key) + sub(/:[[:space:]]*$/, "", key) + if (key == profile) { in_target = 1 } + } + next + } + in_target { + if ($0 ~ /^ [^[:space:]]/) { in_target = 0; next } + if ($0 ~ /^[[:space:]]+inherits_from:[[:space:]]*/) { + line = $0 + sub(/^[[:space:]]*inherits_from:[[:space:]]*/, "", line) + sub(/[[:space:]]+$/, "", line) + print line + exit + } + } + ' "$file" +} + +# Build the inheritance chain base-first, rejecting invalid names, symlinked +# profile directories, missing profiles and inheritance cycles. +ico_profile_chain() { + local config=$1 profiles_dir=$2 start=$3 + local chain="" visited="" current="$start" parent + while [ -n "$current" ]; do + if ! ico_profile_name_ok "$current"; then + ico_die "invalid profile name in inheritance chain: $current" + fi + case "$visited" in + *"|$current|"*) ico_die "circular profile inheritance detected at: $current" ;; + esac + if [ -L "$profiles_dir/$current" ]; then + ico_die "profile directory is a symlink: $current" + fi + if [ ! -d "$profiles_dir/$current" ]; then + ico_die "profile not found: $current" + fi + visited="$visited|$current|" + if [ -n "$chain" ]; then + chain="$current +$chain" + else + chain="$current" + fi + parent=$(ico_config_inherits_from "$config" "$current") + current="$parent" + done + printf '%s\n' "$chain" +} + +# --------------------------------------------------------------------------- +# Manifest I/O +# --------------------------------------------------------------------------- + +# True when a non-empty file's final byte is a newline, using only `tail -c` +# (available on both BSD/macOS and GNU coreutils; no GNU-only option). Every +# manifest consumer reads rows with a plain `while read` loop that silently +# drops an unterminated final row, so such a manifest must be rejected rather +# than partially trusted. +ico_file_ends_with_newline() { + [ "$(tail -c 1 "$1")" = "" ] +} + +# Strictly validate a manifest: non-empty header, TSV shape, lowercase SHA-256 +# format, safe/owned path, no duplicate rows and no stray carriage returns. The +# manifest must be newline-terminated so no consumer can silently drop a final +# unterminated row. +ico_manifest_validate() { + local file=$1 line first=1 lineno=0 hash path seen=$'\n' + if [ -L "$file" ]; then + ico_die "refusing to read symlink manifest: $file" + fi + if [ ! -e "$file" ]; then + ico_die "manifest not found: $file" + fi + if [ ! -f "$file" ]; then + ico_die "manifest is not a regular file: $file" + fi + if [ -s "$file" ] && ! ico_file_ends_with_newline "$file"; then + ico_die "manifest is not newline-terminated: $file" + fi + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + if [ "$first" -eq 1 ]; then + first=0 + if [ "$line" != "$ICO_MANIFEST_HEADER" ]; then + ico_die "invalid manifest header on line $lineno" + fi + continue + fi + case "$line" in + *$'\t'*) ;; + *) ico_die "invalid manifest row without tab on line $lineno" ;; + esac + hash=${line%%$'\t'*} + path=${line#*$'\t'} + case "$path" in + *$'\t'*) ico_die "invalid manifest row with extra tab on line $lineno" ;; + esac + if ! ico_is_sha256 "$hash"; then + ico_die "invalid manifest hash on line $lineno" + fi + if ! ico_path_ok "$path"; then + ico_die "invalid manifest path on line $lineno: $path" + fi + if ! ico_path_owned "$path"; then + ico_die "manifest path outside owned prefixes on line $lineno: $path" + fi + if printf '%s\n' "$seen" | grep -Fqx -- "$path"; then + ico_die "duplicate manifest path on line $lineno: $path" + fi + seen="$seen$path"$'\n' + done < "$file" + if [ "$first" -eq 1 ]; then + ico_die "empty manifest (missing header): $file" + fi +} + +# Validate a manifest and prove that the manifest itself, and every managed +# path, is reachable without traversing a symlinked parent directory. The +# manifest's own parent is checked *before* the manifest is read, so a hostile +# parent symlink can never redirect the read; each managed path is then checked +# before the caller performs any mutation. +ico_preflight_managed_paths() { + local root=$1 manifest=$2 hash path + ico_assert_parent_safe "$root" "$ICO_MANIFEST_REL" + ico_manifest_validate "$manifest" + while IFS=$'\t' read -r hash path; do + [ -n "$hash" ] || continue + ico_assert_parent_safe "$root" "$path" + done < <(ico_manifest_each "$manifest") +} + +# Print the data rows (everything after the header). +ico_manifest_each() { + tail -n +2 "$1" +} + +# Print the recorded hash for a path, or nothing when it is not tracked. +ico_manifest_hash() { + local file=$1 want=$2 hash path + while IFS=$'\t' read -r hash path; do + [ -n "$hash" ] || continue + if [ "$path" = "$want" ]; then + printf '%s\n' "$hash" + return 0 + fi + done < <(ico_manifest_each "$file") + return 1 +} diff --git a/scripts/project-install.sh b/scripts/project-install.sh index 3f7b34a4..ca84c9f7 100755 --- a/scripts/project-install.sh +++ b/scripts/project-install.sh @@ -1,477 +1,939 @@ -#!/bin/bash +#!/usr/bin/env bash +# +# Agent OS project installer (hardened). +# +# Installs Agent OS standards from a profile (optionally inherited) into a +# project's agent-os/standards directory and, for the claude target, the +# agent-os slash commands into .claude/commands/agent-os. +# +# Every write is tracked in agent-os/install-manifest.tsv so that later runs, +# doctor.sh and uninstall.sh can detect drift and never clobber user edits. -# ============================================================================= -# Agent OS Project Installation Script -# Installs Agent OS into a project's codebase -# ============================================================================= +set -Eeuo pipefail -set -e +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +BASE_DIR=$(cd "$SCRIPT_DIR/.." && pwd -P) -# Get the directory where this script is located -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BASE_DIR="$(dirname "$SCRIPT_DIR")" -PROJECT_DIR="$(pwd)" +# shellcheck source=scripts/installer-common.sh +. "$SCRIPT_DIR/installer-common.sh" -# Source common functions -source "$SCRIPT_DIR/common-functions.sh" +PROGRAM=$(basename "$0") -# ----------------------------------------------------------------------------- -# Default Values -# ----------------------------------------------------------------------------- - -VERBOSE="false" +# Option state ----------------------------------------------------------------- +TARGET="claude" PROFILE="" -COMMANDS_ONLY="false" +PROJECT_DIR="" +COMMANDS_ONLY=false +DRY_RUN=false +ASSUME_YES=false +FORCE=false +VERBOSE=false + +# Resolved state --------------------------------------------------------------- +CHAIN="" +MANIFEST="" +MANIFEST_REL="$ICO_MANIFEST_REL" +TARGET_PATHS="" +CONFLICTS="" +EXISTING=0 +SUPPLIED_INDEX="" +COVERS_STANDARDS=true +COVERS_COMMANDS=true + +WORK_DIR="" +STAGE="" +ROLLBACK_ARMED=false +COMMIT_TMP="" +commit_count=0 +SNAP_COUNT=0 +CREATED_DIR_COUNT=0 +CREATED_DIRS=() + +SNAP_PATHS=() +SNAP_BACKUPS=() +SNAP_HAD=() # ----------------------------------------------------------------------------- -# Help Function +# Help # ----------------------------------------------------------------------------- show_help() { - cat << EOF -Usage: $0 [OPTIONS] + cat < Use specified profile (default: from config.yml) - --commands-only Only update commands, preserve existing standards - --verbose Show detailed output - -h, --help Show this help message + --project-dir Target project directory (default: current directory) + --profile Profile to install (default: default_profile in config.yml) + --target "claude" installs .claude/commands/agent-os, "none" installs + standards only (default: claude) + --commands-only Update only commands, leave existing standards untouched + --dry-run Print the plan without changing the project + --yes Assume yes for any confirmation prompt + --force Overwrite unmanaged/modified files, backing them up first + --verbose Show detailed progress + -h, --help Show this help message Examples: - $0 - $0 --profile rails - $0 --commands-only - + $PROGRAM + $PROGRAM --profile rails --target none + $PROGRAM --project-dir ../app --commands-only --yes + $PROGRAM --dry-run EOF exit 0 } # ----------------------------------------------------------------------------- -# Parse Command Line Arguments +# Argument parsing # ----------------------------------------------------------------------------- parse_arguments() { - while [[ $# -gt 0 ]]; do - case $1 in + while [ $# -gt 0 ]; do + case "$1" in + --project-dir) + if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then + ico_die "option --project-dir requires a value" + fi + PROJECT_DIR="$2" + shift 2 + ;; --profile) + if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then + ico_die "option --profile requires a value" + fi PROFILE="$2" shift 2 ;; + --target) + if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then + ico_die "option --target requires a value" + fi + TARGET="$2" + shift 2 + ;; --commands-only) - COMMANDS_ONLY="true" + COMMANDS_ONLY=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --yes|-y) + ASSUME_YES=true + shift + ;; + --force) + FORCE=true shift ;; --verbose) - VERBOSE="true" + VERBOSE=true shift ;; -h|--help) show_help ;; + -*) + ico_die "unknown option: $1" + ;; *) - print_error "Unknown option: $1" - show_help + ico_die "unexpected argument: $1" ;; esac done } # ----------------------------------------------------------------------------- -# Validation Functions +# Validation # ----------------------------------------------------------------------------- -validate_base_installation() { - if [[ ! -d "$BASE_DIR" ]]; then - print_error "Agent OS base installation not found" - exit 1 +validate_options() { + case "$TARGET" in + claude|none) ;; + *) ico_die "invalid --target: $TARGET (expected 'claude' or 'none')" ;; + esac + if [ "$COMMANDS_ONLY" = true ] && [ "$TARGET" = "none" ]; then + ico_die "--commands-only cannot be combined with --target none (nothing would be installed)" fi - - if [[ ! -f "$BASE_DIR/config.yml" ]]; then - print_error "Base installation config.yml not found" - exit 1 + if [ -n "$PROFILE" ]; then + ico_profile_name_ok "$PROFILE" || ico_die "invalid profile name: $PROFILE" fi } -validate_not_in_base() { - if [[ "$PROJECT_DIR" == "$BASE_DIR" ]]; then - print_error "Cannot install Agent OS in the base installation directory" - echo "" - echo "Navigate to your project directory first:" - echo " cd /path/to/your/project" - echo "" - exit 1 - fi +validate_base_installation() { + [ -d "$BASE_DIR" ] || ico_die "Agent OS base installation not found: $BASE_DIR" + [ -f "$BASE_DIR/config.yml" ] || ico_die "missing config.yml in $BASE_DIR" + [ -d "$BASE_DIR/profiles" ] || ico_die "missing profiles directory in $BASE_DIR" } -# ----------------------------------------------------------------------------- -# Configuration Functions -# ----------------------------------------------------------------------------- - -load_configuration() { - local config_file="$BASE_DIR/config.yml" - - # Get default profile from config - local default_profile=$(get_yaml_value "$config_file" "default_profile" "default") - - # Use command line profile or default - EFFECTIVE_PROFILE="${PROFILE:-$default_profile}" - - # Validate profile exists - if [[ ! -d "$BASE_DIR/profiles/$EFFECTIVE_PROFILE" ]]; then - print_error "Profile not found: $EFFECTIVE_PROFILE" - exit 1 - fi +# Reject symlinked live source roots so a profile/command tree cannot be +# redirected outside the base installation. +validate_source_roots() { + local p + for p in profiles commands commands/agent-os; do + if [ -L "$BASE_DIR/$p" ]; then + ico_die "source root is a symlink: $p" + fi + done +} - # Build inheritance chain - local chain_result=$(get_profile_inheritance_chain "$config_file" "$EFFECTIVE_PROFILE" "$BASE_DIR/profiles") - - # Check for errors - if [[ "$chain_result" == CIRCULAR:* ]]; then - local cycle_path="${chain_result#CIRCULAR:}" - echo "" - print_error "Circular dependency detected in profile inheritance chain:" - echo " $cycle_path" - echo "" - echo "Please fix the inheritance configuration in:" - echo " $config_file" - echo "" - echo "The 'profiles' section contains a circular reference that must be resolved." - exit 1 +resolve_project() { + local dir=${PROJECT_DIR:-$PWD} + PROJECT_DIR=$(ico_resolve_project_dir "$dir") + if [ "$PROJECT_DIR" = "$BASE_DIR" ]; then + ico_die "cannot install into the Agent OS base installation directory: $BASE_DIR" fi - - if [[ "$chain_result" == NOTFOUND:* ]]; then - local missing_profile="${chain_result#NOTFOUND:}" - print_error "Profile not found: $missing_profile" - echo "" - echo "This profile is referenced in the inheritance chain but doesn't exist." - echo "Check the 'profiles' section in: $config_file" - exit 1 + MANIFEST="$PROJECT_DIR/$MANIFEST_REL" + if [ -e "$MANIFEST" ] || [ -L "$MANIFEST" ]; then + ico_manifest_validate "$MANIFEST" fi - - # Store the inheritance chain (newline-separated, base first) - INHERITANCE_CHAIN="$chain_result" - - print_verbose "Using profile: $EFFECTIVE_PROFILE" - print_verbose "Inheritance chain: $(echo "$INHERITANCE_CHAIN" | tr '\n' ' ')" } -# ----------------------------------------------------------------------------- -# Confirmation Functions -# ----------------------------------------------------------------------------- - -confirm_standards_overwrite() { - if [[ "$COMMANDS_ONLY" == "true" ]]; then - return 0 +load_profile_chain() { + local default_profile + default_profile=$(ico_config_default_profile "$BASE_DIR/config.yml") + if [ -z "$PROFILE" ]; then + PROFILE="$default_profile" fi + ico_profile_name_ok "$PROFILE" || ico_die "invalid profile name: $PROFILE" + if [ -L "$BASE_DIR/profiles/$PROFILE" ]; then + ico_die "profile directory is a symlink: $PROFILE" + fi + if [ ! -d "$BASE_DIR/profiles/$PROFILE" ]; then + ico_die "profile not found: $PROFILE" + fi + CHAIN=$(ico_profile_chain "$BASE_DIR/config.yml" "$BASE_DIR/profiles" "$PROFILE") - local existing_standards="$PROJECT_DIR/agent-os/standards" - - if [[ -d "$existing_standards" ]]; then - echo "" - print_warning "Existing standards folder detected at: $existing_standards" - echo "" - echo "This will overwrite your existing standards with standards from the '$EFFECTIVE_PROFILE' profile." - echo "" - read -p "Do you want to continue? (y/N) " -n 1 -r - echo "" - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "" - echo "Installation cancelled." - echo "" - echo "To update only commands without touching standards, use:" - echo " $0 --commands-only" - echo "" - exit 0 - fi + COVERS_STANDARDS=true + COVERS_COMMANDS=true + if [ "$COMMANDS_ONLY" = true ]; then + COVERS_STANDARDS=false + fi + if [ "$TARGET" = "none" ]; then + COVERS_COMMANDS=false fi } # ----------------------------------------------------------------------------- -# Installation Functions +# Staging # ----------------------------------------------------------------------------- -create_project_structure() { - print_status "Creating project structure..." +setup_workdir() { + WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-install.XXXXXX") + STAGE="$WORK_DIR/stage" + mkdir -p "$STAGE" +} - ensure_dir "$PROJECT_DIR/agent-os" - ensure_dir "$PROJECT_DIR/agent-os/standards" +stage_standards() { + local name proot rel abs src dest idx list + SUPPLIED_INDEX="" + while IFS= read -r name; do + [ -n "$name" ] || continue + proot="$BASE_DIR/profiles/$name" + idx="$proot/index.yml" + if [ -L "$idx" ]; then + ico_die "profile index is a symlink: $name/index.yml" + fi + if [ -f "$idx" ]; then + SUPPLIED_INDEX="$idx" + fi + list="$WORK_DIR/find.standards.$name" + if ! ( cd "$proot" && find . \( -type f -name '*.md' -o -type l \) -print0 ) >"$list"; then + ico_die "failed to scan profile '$name' for standards" + fi + while IFS= read -r -d '' abs; do + rel=${abs#./} + if ! ico_path_ok "$rel"; then + ico_die "unsafe path in profile '$name': $rel" + fi + case "/$rel/" in + */.backups/*) continue ;; + esac + case "$rel" in + index.yml) continue ;; + esac + src="$proot/$rel" + if [ -L "$src" ]; then + ico_die "profile file is a symlink: $name/$rel" + fi + dest="$STAGE/agent-os/standards/$rel" + mkdir -p "$(dirname "$dest")" + cp -p "$src" "$dest" + done <"$list" + done <<<"$CHAIN" +} - print_success "Created agent-os/ directory structure" +stage_index() { + local standards_dir="$STAGE/agent-os/standards" + local out="$standards_dir/index.yml" + mkdir -p "$standards_dir" + if [ -n "$SUPPLIED_INDEX" ]; then + cp -p "$SUPPLIED_INDEX" "$out" + ico_info "using profile-supplied index.yml" + else + generate_index "$standards_dir" "$PROJECT_DIR/agent-os/standards/index.yml" "$out" + fi } -install_standards() { - if [[ "$COMMANDS_ONLY" == "true" ]]; then - print_status "Skipping standards (--commands-only)" - return +# Build a nested-path-aware index.yml that the existing commands understand. +generate_index() { + local dir=$1 old=$2 out=$3 + local lookup="$WORK_DIR/index.lookup" + local entries="$WORK_DIR/index.entries" + local list="$WORK_DIR/index.files" + : >"$lookup" + : >"$entries" + + if [ -f "$old" ] && [ ! -L "$old" ]; then + if ! index_parse_descriptions "$old" >>"$lookup"; then + ico_die "unsupported or malformed standards index at $old; refusing to reset descriptions to the default" + fi fi - echo "" - print_status "Installing standards..." + if ! find "$dir" -type f -name '*.md' -print0 >"$list"; then + ico_die "failed to scan staged standards for index generation" + fi - local project_standards="$PROJECT_DIR/agent-os/standards" - local profiles_used=0 + local abs rel name dpart label key sk desc + while IFS= read -r -d '' abs; do + rel=${abs#"$dir/"} + name=${rel##*/} + name=${name%.md} + dpart=${rel%/*} + if [ "$dpart" = "$rel" ]; then + dpart="" + fi + if [ -z "$dpart" ]; then + label="root"; key="root/$name"; sk="0" + else + label="$dpart"; key="$dpart/$name"; sk="1" + fi + desc=$(index_lookup "$lookup" "$key") + if [ -z "$desc" ]; then + desc="$ICO_DEFAULT_DESCRIPTION" + fi + case "$desc" in + *$'\t'*) desc="$ICO_DEFAULT_DESCRIPTION" ;; + esac + printf '%s\t%s\t%s\t%s\n' "$sk" "$label" "$name" "$desc" >>"$entries" + done <"$list" + + { + printf '# Agent OS Standards Index\n' + sort -t "$(printf '\t')" -k1,1 -k2,2 -k3,3 "$entries" | { + local prev="" + while IFS=$'\t' read -r sk label name desc; do + if [ "$label" != "$prev" ]; then + printf '\n%s:\n' "$(ico_yaml_scalar "$label")" + prev="$label" + fi + printf ' %s:\n description: %s\n' "$(ico_yaml_scalar "$name")" "$(ico_yaml_scalar "$desc")" + done + } + } >"$out" +} - # Temp file to track file sources (format: relative_path|profile_name) - local sources_file=$(mktemp) - trap "rm -f $sources_file" EXIT +# Read an existing generated index back into "folder/namedescription" pairs +# so custom descriptions survive regeneration. Only the simple shape the +# installer itself emits is understood -- folder -> name -> description, with +# two-space name indentation, four-space description indentation and flat +# nested-path folder keys such as api/auth -- where every key and value is a +# plain, single-quoted or double-quoted YAML scalar. Separators and #-comments +# are recognised only outside quotes, so a literal hash#name stays data. The +# file is never sourced or evaluated. Anything the reader cannot fully +# understand is a hard error before any project mutation instead of a silent +# reset to default descriptions: unsupported structure, an unsupported escape +# or malformed quoting, a duplicate folder or name key (which would otherwise +# be silently first-win), a name left without a description before the next +# key or the end of the file, a plain key or value that begins with a YAML +# indicator ([ { ! & * | > and friends), or a literal tab or carriage return +# inside a quoted scalar (which the TSV round-trip and the trailing-CR strip +# would otherwise silently lose). CRLF input is supported by stripping only a +# single trailing carriage return from each line. +index_parse_descriptions() { + local file="$1" prog="$WORK_DIR/index-parse.awk" + cat >"$prog" <<'AGENT_OS_AWK' +function fail(msg) { + if (!failed) print "index parse error: " msg > "/dev/stderr" + failed = 1 + exit 2 +} - # Process each profile in the inheritance chain (base first, so later ones override) - while IFS= read -r profile_name; do - [[ -z "$profile_name" ]] && continue +function rtrim(s, n, c) { + n = length(s) + while (n > 0) { + c = substr(s, n, 1) + if (c == " " || c == TAB) { n--; continue } + break + } + return substr(s, 1, n) +} - local profile_standards="$BASE_DIR/profiles/$profile_name/standards" +# A plain (unquoted) scalar may not begin with a YAML indicator: a flow +# collection ([ ] { } ,), a node property (! &), an alias (*), a block scalar +# (| >), a directive or reserved character ( % @ `) -- or "-", "?" or ":" +# when they stand alone or are followed by whitespace, where the node kind +# would change and the plain reading would lose that meaning. +function bad_plain(s, c, d) { + c = substr(s, 1, 1) + if (c == "[" || c == "]" || c == "{" || c == "}" || c == ",") return 1 + if (c == "&" || c == "*" || c == "!" || c == "|" || c == ">") return 1 + if (c == "%" || c == "@" || c == "`") return 1 + if (c == "-" || c == "?" || c == ":") { + d = substr(s, 2, 1) + if (d == "" || d == " " || d == TAB) return 1 + } + return 0 +} - if [[ ! -d "$profile_standards" ]]; then +function quoted(s, p, n, i, c, out) { + n = length(s) + out = "" + if (substr(s, p, 1) == "'") { + i = p + 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == TAB || c == CR) fail("tab or carriage return inside a quoted scalar") + if (c == "'") { + if (substr(s, i + 1, 1) == "'") { out = out "'"; i += 2; continue } + sc_end = i + 1 + return out + } + out = out c + i++ + } + fail("unterminated single-quoted scalar") + } + i = p + 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == TAB || c == CR) fail("tab or carriage return inside a quoted scalar") + if (c == BS) { + c = substr(s, i + 1, 1) + if (c == DQ) out = out DQ + else if (c == BS) out = out BS + else fail("unsupported escape in double-quoted scalar") + i += 2 continue - fi + } + if (c == DQ) { sc_end = i + 1; return out } + out = out c + i++ + } + fail("unterminated double-quoted scalar") +} + +function tail_ok(s, p, n, k, c) { + n = length(s) + k = p + while (k <= n) { + c = substr(s, k, 1) + if (c == " " || c == TAB) { k++; continue } + if (c == "#") return + fail("unexpected trailing content in generated index") + } +} - local profile_file_count=0 +function parse_key(s, n, i) { + n = length(s) + if (substr(s, 1, 1) == "'" || substr(s, 1, 1) == DQ) { + key = quoted(s, 1) + i = sc_end + while (i <= n && substr(s, i, 1) == " ") i++ + if (substr(s, i, 1) != ":") fail("expected ':' after key scalar") + tail_ok(s, i + 1) + return key + } + i = index(s, ":") + if (i == 0) fail("missing ':' after key") + key = rtrim(substr(s, 1, i - 1)) + if (key == "" || substr(key, 1, 1) == " " || substr(key, 1, 1) == TAB) fail("empty or malformed key") + if (bad_plain(key)) fail("unsupported YAML indicator in plain key: " key) + if (index(key, TAB) > 0) fail("tab in plain key") + tail_ok(s, i + 1) + return key +} - # Find all .md files in this profile, excluding .backups - while IFS= read -r -d '' file; do - local relative_path="${file#$profile_standards/}" - local dest_file="$project_standards/$relative_path" +function parse_description(s, n, i, c, val, k) { + n = length(s) + if (substr(s, 1, 11) != "description") fail("only a description is supported here") + i = 12 + while (i <= n && (substr(s, i, 1) == " " || substr(s, i, 1) == TAB)) i++ + if (substr(s, i, 1) != ":") fail("expected ':' after description") + i++ + while (i <= n && (substr(s, i, 1) == " " || substr(s, i, 1) == TAB)) i++ + if (i > n || substr(s, i, 1) == "#") return "" + c = substr(s, i, 1) + if (c == "'" || c == DQ) { + val = quoted(s, i) + tail_ok(s, sc_end) + return val + } + if (bad_plain(substr(s, i))) fail("unsupported YAML indicator in plain value") + val = substr(s, i) + n = length(val) + for (k = 2; k <= n; k++) { + if (substr(val, k, 1) == "#" && (substr(val, k - 1, 1) == " " || substr(val, k - 1, 1) == TAB)) { + val = substr(val, 1, k - 1) + break + } + } + val = rtrim(val) + if (index(val, TAB) > 0) fail("tab in plain value") + if (index(val, ": ") > 0 || index(val, ":" TAB) > 0 || substr(val, length(val), 1) == ":") { + fail("unsupported nested value in generated index") + } + return val +} - ensure_dir "$(dirname "$dest_file")" - cp "$file" "$dest_file" +BEGIN { + TAB = sprintf("%c", 9) + CR = sprintf("%c", 13) + DQ = sprintf("%c", 34) + BS = sprintf("%c", 92) + folder = "" + name = "" +} +{ + line = $0 + n = length(line) + if (n > 0 && substr(line, n, 1) == CR) { line = substr(line, 1, n - 1); n = n - 1 } + if (index(line, CR) > 0) fail("carriage return in index content") + i = 1 + while (i <= n) { + c = substr(line, i, 1) + if (c == " " || c == TAB) { i++; continue } + break + } + if (i > n) next + if (substr(line, i, 1) == "#") next + ind = 0 + while (ind < n && substr(line, ind + 1, 1) == " ") ind++ + if (substr(line, ind + 1, 1) == TAB) fail("tab indentation is not supported") + rest = substr(line, ind + 1) + if (ind == 0) { + if (name != "") fail("name key left without a description before the next folder") + folder = parse_key(rest) + if (folder in seen) fail("duplicate folder key: " folder) + seen[folder] = 1 + name = "" + } else if (ind == 2) { + if (folder == "") fail("name key without a preceding folder") + if (name != "") fail("name key left without a description before the next name") + name = parse_key(rest) + nkey = folder SUBSEP name + if (nkey in seenname) fail("duplicate name key: " folder "/" name) + seenname[nkey] = 1 + } else if (ind == 4) { + if (folder == "" || name == "") fail("description without a folder and name") + desc = parse_description(rest) + print folder "/" name TAB desc + name = "" + } else { + fail("unsupported indentation level " ind) + } +} +END { + if (name != "") fail("name key left without a description at end of index") +} +AGENT_OS_AWK + awk -f "$prog" <"$file" +} - # Track the source - remove old entry if exists, add new one - grep -v "^${relative_path}|" "$sources_file" > "${sources_file}.tmp" 2>/dev/null || true - mv "${sources_file}.tmp" "$sources_file" - echo "${relative_path}|${profile_name}" >> "$sources_file" - (( profile_file_count++ )) || true - done < <(find "$profile_standards" -name "*.md" -type f ! -path "*/.backups/*" -print0 2>/dev/null) +# Print the description recorded for a "folder/name" key, or nothing. Compared +# with a byte-exact bash string test: passing the key to awk -v would +# escape-process a backslash in a filename and silently mismatch the lookup. +index_lookup() { + local file="$1" want="$2" key val + while IFS=$'\t' read -r key val; do + if [ "$key" = "$want" ]; then + printf '%s\n' "$val" + return 0 + fi + done <"$file" + return 0 +} - if [[ "$profile_file_count" -gt 0 ]]; then - (( profiles_used++ )) || true +stage_commands() { + local src_dir="$BASE_DIR/commands/agent-os" + if [ ! -d "$src_dir" ]; then + ico_warn "no commands directory in base installation; skipping commands" + return 0 + fi + local f base dest n=0 list="$WORK_DIR/find.commands" + if ! find "$src_dir" \( -type f -name '*.md' -o -type l \) -print0 >"$list"; then + ico_die "failed to scan commands directory" + fi + while IFS= read -r -d '' f; do + if [ -L "$f" ]; then + ico_die "command source is a symlink: $f" fi - done <<< "$INHERITANCE_CHAIN" + base=${f##*/} + dest="$STAGE/.claude/commands/agent-os/$base" + mkdir -p "$(dirname "$dest")" + cp -p "$f" "$dest" + n=$((n + 1)) + done <"$list" + ico_info "staged $n command(s)" +} - # Count profiles in chain to determine if we show sources - local chain_count=$(echo "$INHERITANCE_CHAIN" | grep -c .) +collect_paths() { + local raw="$WORK_DIR/paths.raw" abs list + : >"$raw" + if [ -d "$STAGE/agent-os/standards" ]; then + list="$WORK_DIR/find.coll.stds" + if ! find "$STAGE/agent-os/standards" -type f -print0 >"$list"; then + ico_die "failed to scan staged standards" + fi + while IFS= read -r -d '' abs; do + printf '%s\n' "${abs#"$STAGE/"}" + done <"$list" >>"$raw" + fi + if [ -d "$STAGE/.claude/commands/agent-os" ]; then + list="$WORK_DIR/find.coll.cmds" + if ! find "$STAGE/.claude/commands/agent-os" -type f -print0 >"$list"; then + ico_die "failed to scan staged commands" + fi + while IFS= read -r -d '' abs; do + printf '%s\n' "${abs#"$STAGE/"}" + done <"$list" >>"$raw" + fi + TARGET_PATHS=$(sort "$raw") +} - # Count and display - local total_count=$(wc -l < "$sources_file" | tr -d ' ') +# ----------------------------------------------------------------------------- +# Preflight and plan +# ----------------------------------------------------------------------------- - if [[ "$total_count" -gt 0 ]]; then - # Sort and display files - only show source if inheritance is present - sort "$sources_file" | while IFS='|' read -r filepath profile; do - if [[ "$chain_count" -gt 1 ]]; then - echo " $filepath (from $profile)" +preflight() { + CONFLICTS="" + EXISTING=0 + local rel dest mh ch + ico_assert_dest_safe "$PROJECT_DIR" "$MANIFEST_REL" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + if ! ico_path_owned "$rel"; then + ico_die "refusing to write outside owned prefixes: $rel" + fi + ico_assert_dest_safe "$PROJECT_DIR" "$rel" + dest="$PROJECT_DIR/$rel" + if [ -e "$dest" ]; then + EXISTING=$((EXISTING + 1)) + mh="" + if [ -f "$MANIFEST" ]; then + mh=$(ico_manifest_hash "$MANIFEST" "$rel") || mh="" + fi + if [ -z "$mh" ]; then + CONFLICTS="${CONFLICTS}unmanaged: $rel"$'\n' else - echo " $filepath" + ch=$(ico_hash_file "$dest") + if [ "$ch" != "$mh" ]; then + CONFLICTS="${CONFLICTS}modified: $rel"$'\n' + fi fi - done - - if [[ "$profiles_used" -gt 1 ]]; then - print_success "Installed $total_count standards files (from $profiles_used profiles)" - else - print_success "Installed $total_count standards files" fi - else - print_success "No standards to install (profile is empty)" - fi + done <<<"$TARGET_PATHS" } -create_index() { - echo "" - print_status "Updating standards index..." - - local standards_dir="$PROJECT_DIR/agent-os/standards" - local index_file="$standards_dir/index.yml" - local temp_file="$standards_dir/.index_temp.yml" - local old_index="" +target_has_path() { + printf '%s\n' "$TARGET_PATHS" | grep -Fqx -- "$1" +} - # Save existing index content for description lookup - if [[ -f "$index_file" ]]; then - old_index=$(cat "$index_file") +# Build the new manifest. Every previously tracked row that this run does not +# rewrite is retained (ownership of stale files is never silently dropped), and +# fresh hashes are recorded for everything staged this run. +build_manifest() { + local out="$STAGE/$MANIFEST_REL" + local rows="$WORK_DIR/manifest.rows" + mkdir -p "$(dirname "$out")" + : >"$rows" + + local hash path rel h + if [ -f "$MANIFEST" ]; then + while IFS=$'\t' read -r hash path; do + [ -n "$hash" ] || continue + if target_has_path "$path"; then + continue + fi + printf '%s\t%s\n' "$hash" "$path" >>"$rows" + done < <(ico_manifest_each "$MANIFEST") fi - local entry_count=0 - local new_count=0 - - # Start fresh - echo "# Agent OS Standards Index" > "$temp_file" - echo "" >> "$temp_file" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + h=$(ico_hash_file "$STAGE/$rel") || ico_die "failed to hash staged file: $rel" + if [ -z "$h" ]; then + ico_die "empty hash for staged file: $rel" + fi + printf '%s\t%s\n' "$h" "$rel" >>"$rows" + done <<<"$TARGET_PATHS" - # Helper to get existing description from old index - # Looks for pattern: folder:\n filename:\n description: ... - get_existing_description() { - local folder="$1" - local filename="$2" + { + printf '%s\n' "$ICO_MANIFEST_HEADER" + sort "$rows" + } >"$out" +} - if [[ -z "$old_index" ]]; then - return 1 +print_plan() { + ico_info "profile '$PROFILE' -> target '$TARGET' (commands-only: $COMMANDS_ONLY, dry-run: $DRY_RUN)" + local rel dest state + while IFS= read -r rel; do + [ -n "$rel" ] || continue + dest="$PROJECT_DIR/$rel" + if [ -e "$dest" ]; then + state="update" + else + state="create" fi + printf ' %-6s %s\n' "$state" "$rel" + done <<<"$TARGET_PATHS" + printf ' %-6s %s\n' "update" "$MANIFEST_REL" + if [ -n "$CONFLICTS" ]; then + ico_warn "conflicts detected:" + printf '%s' "$CONFLICTS" | sed 's/^/ /' >&2 + fi +} - # Use awk to find the description for this folder/file combo - local desc=$(echo "$old_index" | awk -v folder="$folder" -v file="$filename" ' - $0 ~ "^"folder":$" { in_folder=1; next } - /^[a-zA-Z0-9_-]+:$/ { in_folder=0 } - in_folder && $0 ~ "^ "file":$" { in_file=1; next } - in_folder && /^ [a-zA-Z0-9_-]+:$/ { in_file=0 } - in_folder && in_file && /description:/ { - sub(/^[[:space:]]*description:[[:space:]]*/, "") - print - exit - } - ') +confirm_or_abort() { + if [ "$ASSUME_YES" = true ]; then + return 0 + fi + if [ ! -t 0 ]; then + return 0 + fi + if [ "$EXISTING" -eq 0 ]; then + return 0 + fi + printf 'Existing Agent OS files will be updated. Continue? (y/N) ' >&2 + local reply="" + if ! read -r reply; then + reply="" + fi + case "$reply" in + [Yy]*) return 0 ;; + *) ico_die "installation cancelled" ;; + esac +} - if [[ -n "$desc" && "$desc" != "Needs description - run /index-standards" ]]; then - echo "$desc" - return 0 - fi - return 1 - } +backup_conflicts() { + [ -n "$CONFLICTS" ] || return 0 + local dir line rel n=0 + dir=$(ico_make_backup_dir "$PROJECT_DIR" "$(date -u +%Y%m%dT%H%M%SZ)") + while IFS= read -r line; do + [ -n "$line" ] || continue + rel=${line#*: } + mkdir -p "$dir/$(dirname "$rel")" + cp -p "$PROJECT_DIR/$rel" "$dir/$rel" + n=$((n + 1)) + done <<<"$CONFLICTS" + ico_warn "backed up $n conflicting file(s) to ${dir#"$PROJECT_DIR"/}/" +} - # First, handle root-level .md files (not in subfolders) - local root_files=$(find "$standards_dir" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort) - if [[ -n "$root_files" ]]; then - echo "root:" >> "$temp_file" - while IFS= read -r file; do - local filename=$(basename "$file" .md) - local desc=$(get_existing_description "root" "$filename") - if [[ -z "$desc" ]]; then - desc="Needs description - run /index-standards" - (( new_count++ )) || true - fi - echo " $filename:" >> "$temp_file" - echo " description: $desc" >> "$temp_file" - (( entry_count++ )) || true - done <<< "$root_files" - echo "" >> "$temp_file" - fi - - # Then handle files in subfolders - local folders=$(find "$standards_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) - for folder in $folders; do - local folder_name=$(basename "$folder") - local md_files=$(find "$folder" -name "*.md" -type f 2>/dev/null | sort) - - if [[ -n "$md_files" ]]; then - echo "$folder_name:" >> "$temp_file" - while IFS= read -r file; do - local filename=$(basename "$file" .md) - local desc=$(get_existing_description "$folder_name" "$filename") - if [[ -z "$desc" ]]; then - desc="Needs description - run /index-standards" - (( new_count++ )) || true - fi - echo " $filename:" >> "$temp_file" - echo " description: $desc" >> "$temp_file" - (( entry_count++ )) || true - done <<< "$md_files" - echo "" >> "$temp_file" +# ----------------------------------------------------------------------------- +# Commit with rollback +# ----------------------------------------------------------------------------- + +# Record the directories that do not yet exist along a destination path, so a +# failed commit can remove directories it created. +record_created_dirs() { + local target=$1 root=$PROJECT_DIR rel cur part rest + case "$target" in + "$root"/*) rel=${target#"$root"/} ;; + *) return 0 ;; + esac + cur=$root + rest=$rel + while [ -n "$rest" ]; do + case "$rest" in + */*) part=${rest%%/*}; rest=${rest#*/} ;; + *) part=$rest; rest="" ;; + esac + cur="$cur/$part" + if [ ! -e "$cur" ]; then + CREATED_DIRS[CREATED_DIR_COUNT]="$cur" + CREATED_DIR_COUNT=$((CREATED_DIR_COUNT + 1)) fi done +} - # Move temp file to final location - mv "$temp_file" "$index_file" - - if [[ "$entry_count" -gt 0 ]]; then - if [[ "$new_count" -gt 0 ]]; then - print_success "Updated index.yml ($entry_count entries, $new_count new)" - else - print_success "Updated index.yml ($entry_count entries)" +snapshot_path() { + local rel=$1 + local dest="$PROJECT_DIR/$rel" idx=$SNAP_COUNT backup + if [ -e "$dest" ] || [ -L "$dest" ]; then + mkdir -p "$WORK_DIR/rollback" + backup="$WORK_DIR/rollback/$idx" + cp -p "$dest" "$backup" || return 1 + if [ ! -f "$backup" ]; then + return 1 fi + SNAP_HAD[idx]="1" + SNAP_BACKUPS[idx]="$backup" else - print_success "Created index.yml (no standards to index)" + SNAP_HAD[idx]="0" + SNAP_BACKUPS[idx]="" fi + SNAP_PATHS[idx]="$rel" + SNAP_COUNT=$((SNAP_COUNT + 1)) } -install_commands() { - echo "" - print_status "Installing commands..." - - local commands_source="$BASE_DIR/commands/agent-os" - local commands_dest="$PROJECT_DIR/.claude/commands/agent-os" - - if [[ ! -d "$commands_source" ]]; then - print_warning "No commands found in base installation" - return +# Write one file atomically: stage a sibling temp file on the same filesystem, +# snapshot first, then rename it into place. +commit_one() { + local rel=$1 staged=$2 + local dest="$PROJECT_DIR/$rel" d tmp + d=$(dirname "$dest") + record_created_dirs "$d" + mkdir -p "$d" + if ! snapshot_path "$rel"; then + ico_die "could not snapshot $rel before writing" fi + ROLLBACK_ARMED=true + ico_assert_dest_safe "$PROJECT_DIR" "$rel" + tmp=$(mktemp "$d/.agent-os-staging.XXXXXX") || ico_die "could not create staging file for $rel" + COMMIT_TMP="$tmp" + cp -p "$staged" "$tmp" + mv -f "$tmp" "$dest" + COMMIT_TMP="" +} - ensure_dir "$commands_dest" +rollback_commit() { + local i=0 p b had + while [ "$i" -lt "$SNAP_COUNT" ]; do + p=${SNAP_PATHS[$i]} + b=${SNAP_BACKUPS[$i]} + had=${SNAP_HAD[$i]} + if [ "$had" = "1" ]; then + cp -p "$b" "$PROJECT_DIR/$p" || ico_warn "rollback: could not restore $p" + else + rm -f "$PROJECT_DIR/$p" || ico_warn "rollback: could not remove $p" + fi + i=$((i + 1)) + done + SNAP_PATHS=(); SNAP_BACKUPS=(); SNAP_HAD=(); SNAP_COUNT=0 +} - local count=0 - for file in "$commands_source"/*.md; do - if [[ -f "$file" ]]; then - cp "$file" "$commands_dest/" - (( count++ )) || true +# Remove, deepest first (reverse recording order), any directories created +# during a failed commit. An indexed array plus an explicit counter (Bash 3.2 +# nounset-safe) carries directory names containing spaces or a literal "|" +# through untouched, unlike a pipe- or whitespace-delimited list. +rollback_created_dirs() { + [ "$CREATED_DIR_COUNT" -gt 0 ] || return 0 + local i=$CREATED_DIR_COUNT d + while [ "$i" -gt 0 ]; do + i=$((i - 1)) + d=${CREATED_DIRS[$i]} + [ -n "$d" ] || continue + if rmdir "$d" 2>/dev/null; then + ico_info "rollback: removed created directory ${d#"$PROJECT_DIR"/}" fi done + CREATED_DIRS=() + CREATED_DIR_COUNT=0 +} - if [[ "$count" -gt 0 ]]; then - print_success "Installed $count commands to .claude/commands/agent-os/" - else - print_warning "No command files found" +commit_all() { + local rel + while IFS= read -r rel; do + [ -n "$rel" ] || continue + commit_one "$rel" "$STAGE/$rel" + commit_count=$((commit_count + 1)) + if [ -n "${AGENT_OS_INSTALL_FAIL_AFTER:-}" ] && [ "$commit_count" -ge "$AGENT_OS_INSTALL_FAIL_AFTER" ]; then + ico_die "test hook: injected failure after $commit_count committed file(s)" + fi + done <<<"$TARGET_PATHS" + commit_one "$MANIFEST_REL" "$STAGE/$MANIFEST_REL" + ROLLBACK_ARMED=false +} + +cleanup_workdir() { + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + rm -rf "$WORK_DIR" fi + return 0 +} + +on_exit() { + local rc=$? + trap - EXIT + if [ "$ROLLBACK_ARMED" = true ]; then + ico_warn "installation failed; rolling back partial changes" + rollback_commit + rollback_created_dirs + fi + if [ -n "$COMMIT_TMP" ]; then + rm -f "$COMMIT_TMP" + fi + cleanup_workdir + exit "$rc" +} + +on_signal() { + ROLLBACK_ARMED=true + exit 130 } # ----------------------------------------------------------------------------- -# Main Execution +# Main # ----------------------------------------------------------------------------- main() { - print_section "Agent OS Project Installation" - - # Parse arguments parse_arguments "$@" - - # Validations - validate_not_in_base + validate_options validate_base_installation + validate_source_roots + resolve_project + load_profile_chain + setup_workdir + + if [ "$VERBOSE" = true ]; then + ico_info "base installation: $BASE_DIR" + ico_info "project directory: $PROJECT_DIR" + ico_info "inheritance chain: $(printf '%s' "$CHAIN" | tr '\n' ' ')" + fi - # Load configuration - load_configuration - - # Show configuration - echo "" - print_status "Configuration:" - - # Display inheritance chain - local chain_depth=0 - local chain_display="" - # Read chain in reverse order (from requested profile back to base) for display - local reversed_chain=$(echo "$INHERITANCE_CHAIN" | awk '{a[NR]=$0} END{for(i=NR;i>=1;i--)print a[i]}') - while IFS= read -r profile_name; do - [[ -z "$profile_name" ]] && continue - if [[ "$chain_depth" -eq 0 ]]; then - chain_display=" Profile: $profile_name" - else - local indent="" - for ((i=0; i&2 + ico_err "re-run with --force to back them up and overwrite" + exit 1 + fi - echo "" - print_success "Agent OS installed successfully!" - echo "" - echo "Next steps:" - echo " 1. Run /discover-standards to extract patterns from your codebase" - echo " 2. Run /inject-standards to inject standards into your context" - echo "" + confirm_or_abort + if [ "$FORCE" = true ]; then + backup_conflicts + fi + commit_all + ico_ok "installed $(printf '%s\n' "$TARGET_PATHS" | wc -l | tr -d ' ') file(s); manifest: $MANIFEST_REL" } -# Run main function +trap on_exit EXIT +trap on_signal INT TERM HUP + main "$@" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 00000000..026a221e --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# Agent OS uninstaller. +# +# Removes only the files that are listed in agent-os/install-manifest.tsv and +# are still unchanged. Modified files are retained by default and reported as a +# non-zero exit status; with --force they are backed up and removed. Symlinks are +# never followed or deleted. Every managed path is preflighted (including its +# parent directories and the manifest itself) before any file is removed, so a +# redirected parent component cannot make the uninstaller delete outside files. + +set -Eeuo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) + +# shellcheck source=scripts/installer-common.sh +. "$SCRIPT_DIR/installer-common.sh" + +PROGRAM=$(basename "$0") +PROJECT_DIR="" +FORCE=false + +# Global so the EXIT trap can still see it after main() returns. +UNINSTALL_TMP="" + +show_help() { + cat < Project directory to clean (default: current directory) + --force Back up and remove files that drifted from the manifest + -h, --help Show this help message +EOF + exit 0 +} + +parse_arguments() { + while [ $# -gt 0 ]; do + case "$1" in + --project-dir) + if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then + ico_die "option --project-dir requires a value" + fi + PROJECT_DIR="$2" + shift 2 + ;; + --force) + FORCE=true + shift + ;; + -h|--help) + show_help + ;; + -*) + ico_die "unknown option: $1" + ;; + *) + ico_die "unexpected argument: $1" + ;; + esac + done +} + +cleanup_tmp() { + if [ -n "$UNINSTALL_TMP" ] && [ -d "$UNINSTALL_TMP" ]; then + rm -rf "$UNINSTALL_TMP" + fi + return 0 +} + +main() { + parse_arguments "$@" + PROJECT_DIR=$(ico_resolve_project_dir "${PROJECT_DIR:-$PWD}") + + local manifest="$PROJECT_DIR/$ICO_MANIFEST_REL" + if [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; then + ico_err "no install manifest found at $ICO_MANIFEST_REL (nothing to uninstall)" + exit 1 + fi + + # Full preflight (manifest + every path and its parents) before any mutation. + ico_preflight_managed_paths "$PROJECT_DIR" "$manifest" + + local keep backup_dir="" drift=0 + UNINSTALL_TMP=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-uninstall.XXXXXX") + trap cleanup_tmp EXIT + keep="$UNINSTALL_TMP/keep.tsv" + : >"$keep" + + if [ "$FORCE" = true ]; then + backup_dir=$(ico_make_backup_dir "$PROJECT_DIR" "$(date -u +%Y%m%dT%H%M%SZ)-uninstall") + fi + + local removed=0 retained=0 missing=0 hash path dest cur + while IFS=$'\t' read -r hash path; do + [ -n "$hash" ] || continue + dest="$PROJECT_DIR/$path" + if [ -L "$dest" ]; then + ico_warn "retaining drifted (symlink; never followed): $path" + printf '%s\t%s\n' "$hash" "$path" >>"$keep" + retained=$((retained + 1)); drift=$((drift + 1)) + elif [ ! -e "$dest" ]; then + missing=$((missing + 1)) + elif [ ! -f "$dest" ]; then + ico_warn "retaining drifted (not a regular file): $path" + printf '%s\t%s\n' "$hash" "$path" >>"$keep" + retained=$((retained + 1)); drift=$((drift + 1)) + else + cur=$(ico_hash_file "$dest") + if [ "$cur" = "$hash" ]; then + if [ -n "$backup_dir" ]; then + mkdir -p "$backup_dir/$(dirname "$path")" + cp -p "$dest" "$backup_dir/$path" + fi + rm -f "$dest" + removed=$((removed + 1)) + elif [ "$FORCE" = true ]; then + ico_warn "backing up and removing drifted file: $path" + mkdir -p "$backup_dir/$(dirname "$path")" + cp -p "$dest" "$backup_dir/$path" + rm -f "$dest" + removed=$((removed + 1)) + else + ico_warn "retaining drifted (modified): $path" + printf '%s\t%s\n' "$hash" "$path" >>"$keep" + retained=$((retained + 1)); drift=$((drift + 1)) + fi + fi + done < <(ico_manifest_each "$manifest") + + if [ -s "$keep" ]; then + { + printf '%s\n' "$ICO_MANIFEST_HEADER" + sort "$keep" + } >"$manifest" + ico_warn "retained $retained tracked file(s); manifest rewritten at $ICO_MANIFEST_REL" + else + rm -f "$manifest" + fi + + ico_ok "removed $removed file(s), skipped $missing missing, retained $retained drifted" + if [ -n "$backup_dir" ]; then + ico_ok "backups written to ${backup_dir#"$PROJECT_DIR"/}/" + fi + if [ "$drift" -gt 0 ]; then + exit 1 + fi +} + +main "$@" diff --git a/tests/installer.sh b/tests/installer.sh new file mode 100755 index 00000000..8ef1e6f2 --- /dev/null +++ b/tests/installer.sh @@ -0,0 +1,1074 @@ +#!/usr/bin/env bash +# +# Functional tests for the hardened Agent OS installer family: +# scripts/project-install.sh, scripts/doctor.sh and scripts/uninstall.sh. +# +# Portable across macOS Bash 3.2 and Linux Bash 4/5. Each test builds its own +# throwaway base install and project under a temp directory. + +set -Eeuo pipefail +LC_ALL=C +export LC_ALL + +REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +SCRIPTS="$REPO/scripts" + +ROOT=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-tests.XXXXXX") +cleanup_root() { + if [ -n "$ROOT" ] && [ -d "$ROOT" ]; then + rm -rf "$ROOT" + fi + return 0 +} +trap cleanup_root EXIT + +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n' "$1"; } + +# --------------------------------------------------------------------------- +# Assertions (return non-zero on failure so the test subshell aborts) +# --------------------------------------------------------------------------- + +eq() { [ "$1" = "$2" ] || { printf ' eq failed: [%s] != [%s]\n' "$1" "$2"; return 1; }; } +exists() { [ -e "$1" ] || { printf ' expected to exist: %s\n' "$1"; return 1; }; } +absent() { [ ! -e "$1" ] || { printf ' expected to be absent: %s\n' "$1"; return 1; }; } +file_eq() { cmp -s "$1" "$2" || { printf ' files differ: %s vs %s\n' "$1" "$2"; return 1; }; } +contains() { printf '%s' "$1" | grep -qF -- "$2" || { printf ' expected to contain: %s\n' "$2"; return 1; }; } + +expect_fail() { + if "$@" >/dev/null 2>&1; then + printf ' expected failure but command succeeded: %s\n' "$*" + return 1 + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Harness +# +# Each test runs in its own subshell with errexit active, and its status is +# captured without an enclosing conditional, so a failing intermediate +# assertion aborts and fails the test instead of being silently ignored. +# --------------------------------------------------------------------------- + +t() { + local name=$1 out rc + shift + set +e + out=$( ( set -e; "$@" ) 2>&1 ) + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + pass "$name" + else + fail "$name" + printf '%s\n' "$out" | sed 's/^/ /' + fi +} + +new_env() { + NAME=$1 + BASE="$ROOT/$NAME/base" + PROJ="$ROOT/$NAME/proj" + mkdir -p "$BASE" "$PROJ" + cp -R "$SCRIPTS" "$BASE/scripts" + cp -R "$REPO/profiles" "$BASE/profiles" + cp -R "$REPO/commands" "$BASE/commands" + cp "$REPO/config.yml" "$BASE/config.yml" +} + +# Every script under test is launched with "$BASH" (the interpreter running the +# harness), not a bare shebang lookup, so `/bin/bash tests/installer.sh` really +# exercises every script under that same Bash (3.2 on macOS). +install() { "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$PROJ" "$@"; } +doctor() { "$BASH" "$BASE/scripts/doctor.sh" --project-dir "$PROJ" "$@"; } +uninstall() { "$BASH" "$BASE/scripts/uninstall.sh" --project-dir "$PROJ" "$@"; } + +# Deterministic content fingerprint of a project tree. +snapdir() { + ( + cd "$1" || return 1 + find . -type f | LC_ALL=C sort | while IFS= read -r f; do + printf '%s %s\n' "$f" "$(cksum <"$f")" + done + ) +} + +backup_has() { + [ -d "$1" ] || return 1 + [ -n "$(find "$1" -name "$2" 2>/dev/null)" ] +} + +# SHA-256 of a file's *content*, fed on stdin and portable across +# sha256sum/shasum/openssl, so it can be compared against the installer's +# recorded hash regardless of which tool is present. +content_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum <"$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 <"$1" | cut -d' ' -f1 + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 <"$1" | sed 's/.* //' + else + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Harness self-test: without this the suite could report a false pass if the +# runner ever stopped propagating intermediate failures. +# --------------------------------------------------------------------------- + +_selftest_bad() { false; true; } +_selftest_good() { true; } + +test_harness_selfcheck() { + local f0=$FAIL p0=$PASS + t harness_probe_should_fail _selftest_bad + if [ "$FAIL" -ne $((f0 + 1)) ]; then + printf ' harness masked a failure followed by success\n' + return 1 + fi + t harness_probe_should_pass _selftest_good + if [ "$PASS" -ne $((p0 + 1)) ]; then + printf ' harness failed to record a passing test\n' + return 1 + fi + FAIL=$f0 + PASS=$p0 + return 0 +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +test_flat_standard_install() { + new_env flat + install --yes + exists "$PROJ/agent-os/standards/global/tech-stack.md" + file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md" + exists "$PROJ/agent-os/standards/index.yml" + exists "$PROJ/.claude/commands/agent-os/discover-standards.md" + exists "$PROJ/agent-os/install-manifest.tsv" + contains "$(cat "$PROJ/agent-os/standards/index.yml")" "global:" + contains "$(cat "$PROJ/agent-os/standards/index.yml")" "tech-stack:" + contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md" + # manifest itself is bookkeeping, not a managed file + if grep -q "install-manifest" "$PROJ/agent-os/install-manifest.tsv"; then + printf ' manifest must not list itself\n' + return 1 + fi + # no invented skills / optimizer / router artefacts + absent "$PROJ/agent-os/skills" + [ -z "$(find "$PROJ" -name 'agent-os-*' 2>/dev/null)" ] || { printf ' unexpected agent-os-* artefacts\n'; return 1; } +} + +test_index_preservation_and_inheritance() { + new_env idx + mkdir -p "$BASE/profiles/child" + printf '# Extra standard\n' >"$BASE/profiles/child/extra.md" + cat >"$BASE/profiles/child/index.yml" <<'YAML' +root: + extra: + description: Curated description + tags: + - one + - two +YAML + cat >>"$BASE/config.yml" <<'YAML' + +profiles: + child: + inherits_from: default +YAML + install --profile child --yes + # supplied index is copied byte-for-byte, structured metadata included + file_eq "$PROJ/agent-os/standards/index.yml" "$BASE/profiles/child/index.yml" + contains "$(cat "$PROJ/agent-os/standards/index.yml")" "tags:" + exists "$PROJ/agent-os/standards/extra.md" + # inherited standard from the parent profile is present too + exists "$PROJ/agent-os/standards/global/tech-stack.md" +} + +test_index_description_preservation() { + new_env idxdesc + install --yes + cat >"$PROJ/agent-os/standards/index.yml" <<'YAML' +# Agent OS Standards Index + +global: + tech-stack: + description: Custom curated description +YAML + install --force --yes + contains "$(cat "$PROJ/agent-os/standards/index.yml")" "Custom curated description" +} + +test_index_quoting_special_names() { + new_env idxquote + mkdir -p "$BASE/profiles/default/weird" + printf '# Colon standard\n' >"$BASE/profiles/default/weird/colon:name.md" + printf '# Hash standard\n' >"$BASE/profiles/default/weird/hash#name.md" + install --yes + exists "$PROJ/agent-os/standards/weird/colon:name.md" + exists "$PROJ/agent-os/standards/weird/hash#name.md" + contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"colon:name":' + contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"hash#name":' +} + +test_option_errors() { + new_env opts + expect_fail install --target bogus + expect_fail install --profile + expect_fail install --project-dir + expect_fail install --unknown + expect_fail install --profile ../evil + expect_fail install --project-dir "$ROOT/does-not-exist" + expect_fail install extra-arg + absent "$PROJ/agent-os" +} + +test_target_none() { + new_env none + install --target none --yes + exists "$PROJ/agent-os/standards/global/tech-stack.md" + absent "$PROJ/.claude" + # --commands-only with no commands target is a rejected no-op, not a silent success + new_env noneco + expect_fail install --target none --commands-only --yes + absent "$PROJ/agent-os" +} + +test_verbose_and_self_install_guard() { + new_env verbose + install --verbose --yes + exists "$PROJ/agent-os/standards/global/tech-stack.md" + # installing into the base installation itself is refused + expect_fail "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$BASE" --yes + absent "$BASE/agent-os" +} + +test_dry_run_side_effect_free() { + new_env dry + install --dry-run --yes + absent "$PROJ/agent-os" + absent "$PROJ/.claude" + + install --yes + before=$(snapdir "$PROJ") + install --dry-run --yes + after=$(snapdir "$PROJ") + eq "$after" "$before" +} + +test_commands_only_ownership() { + new_env co + install --yes + printf 'user edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md" + # commands-only must not touch (or abort on) the drifted standards + install --commands-only --yes + contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "user edit" + contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md" + exists "$PROJ/.claude/commands/agent-os/discover-standards.md" +} + +test_unchanged_updates() { + new_env upd + install --yes + before=$(snapdir "$PROJ") + install --yes + eq "$(snapdir "$PROJ")" "$before" + printf '\nnew body\n' >>"$BASE/profiles/default/global/tech-stack.md" + install --yes + file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md" +} + +test_stale_rows_retained() { + new_env stale + install --yes + # a managed standard disappears from the source tree + rm -f "$BASE/profiles/default/global/tech-stack.md" + install --yes + # the on-disk file (and its manifest row) is retained, not silently dropped + exists "$PROJ/agent-os/standards/global/tech-stack.md" + contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md" + doctor +} + +test_unmanaged_and_drift_protection() { + new_env prot + # unmanaged file at a managed path, no manifest yet + mkdir -p "$PROJ/agent-os/standards/global" + printf 'user file\n' >"$PROJ/agent-os/standards/global/tech-stack.md" + expect_fail install --yes + eq "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "user file" + # --yes alone must not override the protection + expect_fail install --yes + # --force backs up and overwrites + install --force --yes + file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md" + backup_has "$PROJ/agent-os/.backups" "tech-stack.md" + + # managed file modified after install + new_env prot2 + install --yes + printf 'tampered\n' >>"$PROJ/agent-os/standards/global/tech-stack.md" + expect_fail install --yes + install --force --yes + bk=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | head -n 1) + contains "$(cat "$bk")" "tampered" +} + +test_backup_unique_dirs() { + new_env bkp + mkdir -p "$PROJ/agent-os/standards/global" + printf 'user file\n' >"$PROJ/agent-os/standards/global/tech-stack.md" + install --force --yes + printf 'user file 2\n' >"$PROJ/agent-os/standards/global/tech-stack.md" + install --force --yes + # two backups in the same second must not overwrite each other + count=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | wc -l | tr -d ' ') + eq "$count" "2" +} + +test_symlink_rejection() { + # source leaf symlink in the profile + new_env symsrc + ln -s "$BASE/profiles/default/global/tech-stack.md" "$BASE/profiles/default/evil.md" + expect_fail install --yes + absent "$PROJ/agent-os/standards/evil.md" + + # destination symlink for agent-os/standards + new_env symdst + mkdir -p "$PROJ/agent-os" "$ROOT/elsewhere" + ln -s "$ROOT/elsewhere" "$PROJ/agent-os/standards" + expect_fail install --yes + absent "$ROOT/elsewhere/global/tech-stack.md" + + # symlinked manifest + new_env symmanifest + mkdir -p "$PROJ/agent-os" + ln -s "$ROOT/missing-manifest.tsv" "$PROJ/agent-os/install-manifest.tsv" + expect_fail install --yes + expect_fail doctor +} + +test_source_root_symlinks() { + new_env rootsymprofiles + mv "$BASE/profiles" "$BASE/profiles.real" + ln -s "$BASE/profiles.real" "$BASE/profiles" + expect_fail install --yes + + new_env rootsymcommands + mv "$BASE/commands" "$BASE/commands.real" + ln -s "$BASE/commands.real" "$BASE/commands" + expect_fail install --yes +} + +test_bad_manifest() { + ZERO_HASH="0000000000000000000000000000000000000000000000000000000000000000" + HEADER="# agent-os install manifest v1" + + # traversal path + new_env trav + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "../escape"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # path outside the owned prefixes + new_env trav2 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/product/evil.md"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # extra tab in a row + new_env trav3 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md" "extra"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # non-hex hash + new_env trav4 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "nothex" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # wrong header + new_env trav5 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "# wrong header"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # empty manifest (no header) + new_env trav6 + mkdir -p "$PROJ/agent-os" + : >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor + + # duplicate rows + new_env trav7 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv" + expect_fail doctor +} + +test_filenames_with_spaces() { + new_env space + mkdir -p "$BASE/profiles/default/global" + printf '# Spaced standard\n' >"$BASE/profiles/default/global/my standard.md" + install --yes + exists "$PROJ/agent-os/standards/global/my standard.md" + contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/my standard.md" + doctor + uninstall + absent "$PROJ/agent-os/standards/global/my standard.md" +} + +test_inheritance_guards() { + # circular inheritance + new_env inhcycle + mkdir -p "$BASE/profiles/a" "$BASE/profiles/b" + cat >>"$BASE/config.yml" <<'YAML' + +profiles: + a: + inherits_from: b + b: + inherits_from: a +YAML + expect_fail install --profile a --yes + + # invalid parent name + new_env inhbad + mkdir -p "$BASE/profiles/a" + cat >>"$BASE/config.yml" <<'YAML' + +profiles: + a: + inherits_from: ../evil +YAML + expect_fail install --profile a --yes + + # missing profile + new_env inhmissing + expect_fail install --profile ghost --yes + + # symlinked profile directory + new_env inhlink + ln -s "$BASE/profiles/default" "$BASE/profiles/link" + expect_fail install --profile link --yes +} + +test_inheritance_override() { + new_env ovr + mkdir -p "$BASE/profiles/child/global" + printf '# Child stack\n' >"$BASE/profiles/child/global/tech-stack.md" + cat >>"$BASE/config.yml" <<'YAML' + +profiles: + child: + inherits_from: default +YAML + install --profile child --yes + contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "Child stack" +} + +test_project_dir_canonicalization() { + new_env canon + ln -s "$PROJ" "$ROOT/canon/link" + "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$ROOT/canon/link" --yes + exists "$PROJ/agent-os/standards/global/tech-stack.md" +} + +test_doctor() { + new_env doc + install --yes + doctor + printf 'tampered\n' >>"$PROJ/agent-os/standards/global/tech-stack.md" + expect_fail doctor + rm -f "$PROJ/.claude/commands/agent-os/discover-standards.md" + expect_fail doctor + + new_env doc2 + expect_fail doctor +} + +test_doctor_parent_symlink() { + new_env docparent + install --yes + outside="$ROOT/docparent/outside" + mkdir -p "$outside" + cp -R "$PROJ/agent-os/standards/." "$outside/" + rm -rf "$PROJ/agent-os/standards" + ln -s "$outside" "$PROJ/agent-os/standards" + # a symlinked parent must never be reported as healthy + expect_fail doctor +} + +test_uninstall_preserves_unrelated() { + new_env un + install --yes + printf 'notes\n' >"$PROJ/agent-os/notes.txt" + printf 'keep\n' >"$PROJ/agent-os/standards/mine.md" + printf 'root\n' >"$PROJ/README.md" + uninstall + absent "$PROJ/agent-os/standards/global/tech-stack.md" + absent "$PROJ/.claude/commands/agent-os/discover-standards.md" + absent "$PROJ/agent-os/install-manifest.tsv" + exists "$PROJ/agent-os/notes.txt" + exists "$PROJ/agent-os/standards/mine.md" + exists "$PROJ/README.md" + + # drifted files are retained, stay tracked and make the run non-zero + new_env un2 + install --yes + printf 'edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md" + expect_fail uninstall + exists "$PROJ/agent-os/standards/global/tech-stack.md" + exists "$PROJ/agent-os/install-manifest.tsv" + contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md" + absent "$PROJ/.claude/commands/agent-os/discover-standards.md" + + # --force backs up removed files + new_env un3 + install --yes + uninstall --force + absent "$PROJ/agent-os/standards/global/tech-stack.md" + backup_has "$PROJ/agent-os/.backups" "tech-stack.md" +} + +test_uninstall_force_drift() { + new_env undrift + install --yes + printf 'edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md" + # normal uninstall retains the modification and reports failure + expect_fail uninstall + contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "edit" + # --force backs up and removes the drifted regular file + uninstall --force + absent "$PROJ/agent-os/standards/global/tech-stack.md" + bk=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | head -n 1) + contains "$(cat "$bk")" "edit" +} + +test_uninstall_never_follows_symlinks() { + new_env unsym + install --yes + printf 'secret\n' >"$ROOT/unsym/secret.txt" + rm -f "$PROJ/agent-os/standards/global/tech-stack.md" + ln -s "$ROOT/unsym/secret.txt" "$PROJ/agent-os/standards/global/tech-stack.md" + # even --force must refuse to remove the symlink and never touch its target + expect_fail uninstall --force + [ -L "$PROJ/agent-os/standards/global/tech-stack.md" ] || { printf ' symlink was removed\n'; return 1; } + exists "$ROOT/unsym/secret.txt" +} + +test_parent_symlink_deletion_exploit() { + new_env exploit + install --yes + # replace the standards directory with a symlink to a tree copied outside + outside="$ROOT/exploit/outside" + mkdir -p "$outside" + cp -R "$PROJ/agent-os/standards/." "$outside/" + rm -rf "$PROJ/agent-os/standards" + ln -s "$outside" "$PROJ/agent-os/standards" + exists "$outside/global/tech-stack.md" + expect_fail doctor + expect_fail uninstall --force + # nothing outside the project may have been deleted + exists "$outside/global/tech-stack.md" + exists "$outside/index.yml" +} + +test_rollback_mid_commit() { + new_env rb + install --target none --yes + before=$(snapdir "$PROJ") + # a modified standard and a new standard so the re-run both updates and creates + printf '\nchanged body\n' >>"$BASE/profiles/default/global/tech-stack.md" + printf '# Extra\n' >"$BASE/profiles/default/global/extra.md" + export AGENT_OS_INSTALL_FAIL_AFTER=2 + expect_fail install --target none --yes + unset AGENT_OS_INSTALL_FAIL_AFTER + eq "$(snapdir "$PROJ")" "$before" + absent "$PROJ/agent-os/standards/global/extra.md" + + # failure after the very first write also rolls back cleanly + export AGENT_OS_INSTALL_FAIL_AFTER=1 + expect_fail install --target none --yes + unset AGENT_OS_INSTALL_FAIL_AFTER + eq "$(snapdir "$PROJ")" "$before" +} + +test_rollback_removes_created_dirs() { + new_env rbdir + install --target none --yes + before=$(snapdir "$PROJ") + # a brand new nested directory tree that only this run creates + mkdir -p "$BASE/profiles/default/newdir/deep" + printf '# Deep\n' >"$BASE/profiles/default/newdir/deep/file.md" + export AGENT_OS_INSTALL_FAIL_AFTER=1 + expect_fail install --target none --yes + unset AGENT_OS_INSTALL_FAIL_AFTER + absent "$PROJ/agent-os/standards/newdir" + eq "$(snapdir "$PROJ")" "$before" +} + +test_help() { + new_env help + out=$("$BASH" "$BASE/scripts/project-install.sh" --help) + contains "$out" "Usage:" + contains "$out" "--verbose" + out=$(doctor --help) + contains "$out" "Usage:" + out=$(uninstall --help) + contains "$out" "Usage:" +} + +test_backslash_filename_hash() { + new_env bslash + mkdir -p "$BASE/profiles/default/global" + bs="$BASE/profiles/default/global/back\\slash.md" + printf '# Backslash standard\n' >"$bs" + install --yes + dest="$PROJ/agent-os/standards/global/back\\slash.md" + exists "$dest" + # The recorded hash must equal the content hash (the hashing tool is fed on + # stdin, never the raw backslash-bearing filename). + want=$(content_sha256 "$bs") + row=$(grep -F "$want" "$PROJ/agent-os/install-manifest.tsv") + contains "$row" "back\\slash.md" + doctor + uninstall + absent "$dest" +} + +test_index_quoting_reserved_and_numeric() { + new_env idxres + mkdir -p "$BASE/profiles/default/weird" "$BASE/profiles/default/1st" + for n in true false yes no on off null 2024; do + printf '# %s\n' "$n" >"$BASE/profiles/default/weird/$n.md" + done + printf '# Plain\n' >"$BASE/profiles/default/weird/plainname.md" + printf '# One\n' >"$BASE/profiles/default/1st/plain.md" + install --yes + idx=$(cat "$PROJ/agent-os/standards/index.yml") + # reserved words and digit-leading names stay quoted strings + for n in true false yes no on off null 2024; do + contains "$idx" "\"$n\":" + done + # a digit-leading folder label is quoted too + contains "$idx" '"1st":' + # a plainly-safe name stays an unquoted plain scalar + contains "$idx" ' plainname:' +} + +test_dotted_profile_inheritance() { + new_env dotted + mkdir -p "$BASE/profiles/parent/global" "$BASE/profiles/foo.bar/global" "$BASE/profiles/fooXbar/global" + printf '# Parent stack\n' >"$BASE/profiles/parent/global/tech-stack.md" + printf '# Parent only\n' >"$BASE/profiles/parent/global/parent-only.md" + printf '# Dotted stack\n' >"$BASE/profiles/foo.bar/global/tech-stack.md" + printf '# X stack\n' >"$BASE/profiles/fooXbar/global/tech-stack.md" + cat >>"$BASE/config.yml" <<'YAML' + +profiles: + foo.bar: + inherits_from: parent + fooXbar: + inherits_from: ghost +YAML + # If "foo.bar" were matched as a regex it would resolve to the fooXbar stanza + # and die on the nonexistent "ghost" parent. + install --profile foo.bar --yes + # exact dotted parent override: foo.bar replaces its parent's standard + file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/foo.bar/global/tech-stack.md" + # the parent's other standard still arrives through the exact chain + exists "$PROJ/agent-os/standards/global/parent-only.md" +} + +test_rollback_pipe_space_dirs() { + new_env rbpipe + # Both the project directory name and the created standards directory name + # carry a literal "|" and a space, so rollback cannot rely on a pipe- or + # whitespace-delimited list of created directories. + PROJ="$ROOT/rbpipe/proj with|pipe" + mkdir -p "$PROJ" + install --target none --yes + before=$(snapdir "$PROJ") + # The new directory sorts first, so the first commit creates it (and its + # parent) before the injected failure. + mkdir -p "$BASE/profiles/default/aaa b|c/deep" + printf '# Pipe dir\n' >"$BASE/profiles/default/aaa b|c/deep/file.md" + export AGENT_OS_INSTALL_FAIL_AFTER=1 + expect_fail install --target none --yes + unset AGENT_OS_INSTALL_FAIL_AFTER + absent "$PROJ/agent-os/standards/aaa b|c" + eq "$(snapdir "$PROJ")" "$before" +} + +test_colon_space_force_backup() { + new_env colonspace + mkdir -p "$BASE/profiles/default/weird" + printf '# Colon space\n' >"$BASE/profiles/default/weird/colon: name.md" + # Pre-existing unmanaged file: the conflict line becomes + # "unmanaged: agent-os/standards/weird/colon: name.md". The embedded ": " + # must not be mistaken for the conflict "reason: " separator. + mkdir -p "$PROJ/agent-os/standards/weird" + printf 'user colon file\n' >"$PROJ/agent-os/standards/weird/colon: name.md" + expect_fail install --yes + install --force --yes + file_eq "$PROJ/agent-os/standards/weird/colon: name.md" "$BASE/profiles/default/weird/colon: name.md" + bk=$(find "$PROJ/agent-os/.backups" -name "colon: name.md" | head -n 1) + [ -n "$bk" ] || { printf ' no backup for the colon-space file\n'; return 1; } + contains "$(cat "$bk")" "user colon file" + doctor +} + +test_hash_tool_failure_no_writes() { + new_env hashfail + fake="$ROOT/hashfail/fakebin" + mkdir -p "$fake" + for tool in sha256sum shasum openssl; do + printf '#!/bin/sh\nexit 1\n' >"$fake/$tool" + chmod +x "$fake/$tool" + done + # With every SHA-256 tool failing, hashing must abort the run before any + # project mutation rather than recording empty/garbage hashes. + set +e + PATH="$fake:$PATH" install --yes >/dev/null 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || { printf ' install succeeded despite a failing hash tool\n'; return 1; } + absent "$PROJ/agent-os" +} + +test_manifest_unterminated_rejected() { + new_env term + install --yes + m="$PROJ/agent-os/install-manifest.tsv" + # Drop the manifest's final terminating newline — exactly the corruption that + # previously let doctor exit 0 while silently ignoring the last row. + printf '%s' "$(cat "$m")" >"$m.tmp" && mv "$m.tmp" "$m" + [ -n "$(tail -c 1 "$m")" ] || { printf ' setup: manifest still newline-terminated\n'; return 1; } + before=$(snapdir "$PROJ") + + set +e + out=$(doctor 2>&1); rc=$? + set -e + [ "$rc" -ne 0 ] || { printf ' doctor exited 0 on an unterminated manifest\n'; return 1; } + case "$out" in + *"match the manifest"*) printf ' doctor falsely reported success\n'; return 1 ;; + esac + + expect_fail uninstall + expect_fail install --yes + eq "$(snapdir "$PROJ")" "$before" + + # Restoring the terminator makes the manifest valid again. + printf '\n' >>"$m" + doctor +} + +test_malformed_manifest_no_mutation() { + ZERO_HASH="0000000000000000000000000000000000000000000000000000000000000000" + HEADER="# agent-os install manifest v1" + + # traversal row + new_env badmut1 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "../escape"; } >"$PROJ/agent-os/install-manifest.tsv" + before=$(snapdir "$PROJ") + expect_fail install --yes + expect_fail uninstall + expect_fail doctor + eq "$(snapdir "$PROJ")" "$before" + + # duplicate rows + new_env badmut2 + mkdir -p "$PROJ/agent-os" + { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv" + before=$(snapdir "$PROJ") + expect_fail install --yes + expect_fail uninstall + eq "$(snapdir "$PROJ")" "$before" +} + +test_index_scalar_roundtrip() { + new_env idxscalar + # Real profile keys/folders that force YAML quoting: a space, a colon, a + # literal hash, a double quote, a backslash, a reserved word and a + # digit-leading name. + mkdir -p "$BASE/profiles/default/space dir" "$BASE/profiles/default/weird" + mkdir -p "$BASE/profiles/default/quote\"dir" + printf '# Spaced\n' >"$BASE/profiles/default/space dir/plain.md" + printf '# Colon\n' >"$BASE/profiles/default/weird/colon:name.md" + printf '# Hash\n' >"$BASE/profiles/default/weird/hash#name.md" + printf '# Quote\n' >"$BASE/profiles/default/quote\"dir/plain.md" + printf '# Slash\n' >"$BASE/profiles/default/weird/back\\slash.md" + printf '# True\n' >"$BASE/profiles/default/weird/true.md" + printf '# Digit\n' >"$BASE/profiles/default/weird/123.md" + install --yes + + # Hand-curate the project index with every supported scalar style and + # YAML-significant content (colon, hash, escaped quotes, a backslash and a + # doubled single quote). The reader must decode these exactly. + cat >"$PROJ/agent-os/standards/index.yml" <<'YAML' +# Agent OS Standards Index + +"space dir": + plain: + description: "colon: and hash # inside quotes" + +weird: + "colon:name": + description: 'it''s got a doubled apostrophe' + "hash#name": + description: "escaped \"quote\" and a # too" + "back\\slash": + description: "one\\backslash stays" + "true": + description: plain spaced value + "123": + description: "plain:colon" + +"quote\"dir": + plain: + description: "a \"quoted\" folder" +YAML + + # The 2nd install (forced) must decode and keep every curated description. + install --force --yes + idx=$(cat "$PROJ/agent-os/standards/index.yml") + # emitter-safe plain scalars stay bare; YAML-significant content is quoted + contains "$idx" 'space dir:' + contains "$idx" '"quote\"dir":' + contains "$idx" '"colon:name":' + contains "$idx" '"hash#name":' + contains "$idx" '"colon: and hash # inside quotes"' + contains "$idx" "\"it's got a doubled apostrophe\"" + contains "$idx" '"escaped \"quote\" and a # too"' + contains "$idx" '"one\\backslash stays"' + contains "$idx" 'description: plain spaced value' + contains "$idx" '"plain:colon"' + contains "$idx" '"a \"quoted\" folder"' + + # The 3rd install (forced) must be byte-stable against the 2nd and must + # still verify clean afterwards. + after2=$(snapdir "$PROJ") + install --force --yes + eq "$(snapdir "$PROJ")" "$after2" + contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"one\\backslash stays"' + + # A CRLF index (every line closed with a carriage return) is accepted: only + # a single trailing carriage return is stripped per line, so the curated + # descriptions still decode and survive regeneration to an LF index that is + # byte-stable on the next forced run. + cr=$(printf '\r') + while IFS= read -r line || [ -n "$line" ]; do + printf '%s%s\n' "$line" "$cr" + done <"$PROJ/agent-os/standards/index.yml" >"$PROJ/agent-os/standards/index.crlf" + mv "$PROJ/agent-os/standards/index.crlf" "$PROJ/agent-os/standards/index.yml" + grep -q "$cr" "$PROJ/agent-os/standards/index.yml" || { printf ' setup: no CR introduced\n'; return 1; } + install --force --yes + contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"one\\backslash stays"' + crlf_stable=$(snapdir "$PROJ") + install --force --yes + eq "$(snapdir "$PROJ")" "$crlf_stable" + + doctor +} + +test_index_unsupported_fails_closed() { + # A project index the reader cannot fully understand must abort before any + # project mutation -- using --force so the drift guard is definitively not + # what refuses -- rather than silently resetting descriptions to defaults. + + # Nested/structured metadata under a name. + new_env idxnested + install --yes + cat >"$PROJ/agent-os/standards/index.yml" <<'YAML' +# Agent OS Standards Index + +global: + tech-stack: + description: "kept" + tags: + - one +YAML + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # An unsupported escape sequence in a double-quoted scalar. + new_env idxescape + install --yes + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' description: "bad \t escape"' >"$PROJ/agent-os/standards/index.yml" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # Malformed (unterminated) quoting. + new_env idxunterm + install --yes + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' " description: 'unterminated" >"$PROJ/agent-os/standards/index.yml" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # Unsupported indentation level. + new_env idxindent + install --yes + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' description: x' >"$PROJ/agent-os/standards/index.yml" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # A duplicate folder key (silently first-win otherwise). + new_env idxdupdir + install --yes + cat >"$PROJ/agent-os/standards/index.yml" <<'YAML' +# Agent OS Standards Index + +global: + tech-stack: + description: first +global: + other: + description: second +YAML + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # A duplicate name key under one folder. + new_env idxdupname + install --yes + cat >"$PROJ/agent-os/standards/index.yml" <<'YAML' +# Agent OS Standards Index + +global: + tech-stack: + description: first + tech-stack: + description: second +YAML + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # A name left without a description before the next name, the next folder + # or the end of the file. + new_env idxnodesc + install --yes + idx="$PROJ/agent-os/standards/index.yml" + + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' tech-stack-2:' ' description: x' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' 'other:' ' plain:' ' description: x' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # A plain description value that begins with a YAML indicator: a flow + # collection, a node tag/anchor/alias, a block scalar or a reserved + # character. + new_env idxindicator + install --yes + idx="$PROJ/agent-os/standards/index.yml" + for value in '[flow]' '{flow}' '!tag v' '&anchor v' '*alias' '|block' '>folded' '%dir' '@res' '`res'; do + printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' " description: $value" >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + done + + # ... and a plain key that begins with an indicator. + printf '%s\n' '# Agent OS Standards Index' '' '!tag:' ' tech-stack:' ' description: x' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + # A literal tab inside a double-quoted and a single-quoted scalar, and a + # literal carriage return inside a quoted scalar, which the TSV round-trip + # or the trailing-CR strip would otherwise silently lose. + new_env idxcontrol + install --yes + idx="$PROJ/agent-os/standards/index.yml" + + printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: "tab\tinside"\n' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: '\''tab\tinside'\''\n' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" + + printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: "car\rriage"\n' >"$idx" + before=$(snapdir "$PROJ") + expect_fail install --force --yes + eq "$(snapdir "$PROJ")" "$before" +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +printf 'Agent OS installer tests (bash %s)\n\n' "$BASH_VERSION" + +t harness_selfcheck test_harness_selfcheck +t flat_standard_install test_flat_standard_install +t index_preservation_and_inheritance test_index_preservation_and_inheritance +t index_description_preservation test_index_description_preservation +t index_quoting_special_names test_index_quoting_special_names +t option_errors test_option_errors +t target_none test_target_none +t verbose_and_self_install_guard test_verbose_and_self_install_guard +t dry_run_side_effect_free test_dry_run_side_effect_free +t commands_only_ownership test_commands_only_ownership +t unchanged_updates test_unchanged_updates +t stale_rows_retained test_stale_rows_retained +t unmanaged_and_drift_protection test_unmanaged_and_drift_protection +t backup_unique_dirs test_backup_unique_dirs +t symlink_rejection test_symlink_rejection +t source_root_symlinks test_source_root_symlinks +t bad_manifest test_bad_manifest +t inheritance_guards test_inheritance_guards +t inheritance_override test_inheritance_override +t project_dir_canonicalization test_project_dir_canonicalization +t doctor test_doctor +t doctor_parent_symlink test_doctor_parent_symlink +t uninstall_preserves_unrelated test_uninstall_preserves_unrelated +t uninstall_force_drift test_uninstall_force_drift +t uninstall_never_follows_symlinks test_uninstall_never_follows_symlinks +t parent_symlink_deletion_exploit test_parent_symlink_deletion_exploit +t rollback_mid_commit test_rollback_mid_commit +t rollback_removes_created_dirs test_rollback_removes_created_dirs +t rollback_pipe_space_dirs test_rollback_pipe_space_dirs +t backslash_filename_hash test_backslash_filename_hash +t index_quoting_reserved_numeric test_index_quoting_reserved_and_numeric +t index_scalar_roundtrip test_index_scalar_roundtrip +t index_unsupported_fails_closed test_index_unsupported_fails_closed +t dotted_profile_inheritance test_dotted_profile_inheritance +t colon_space_force_backup test_colon_space_force_backup +t hash_tool_failure_no_writes test_hash_tool_failure_no_writes +t manifest_unterminated_rejected test_manifest_unterminated_rejected +t malformed_manifest_no_mutation test_malformed_manifest_no_mutation +t help test_help + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]