From 8547d6ea3364fea65b2a83b54c082ec854c074d5 Mon Sep 17 00:00:00 2001 From: tmk241 Date: Tue, 11 Aug 2026 17:04:37 +0200 Subject: [PATCH] Import verified skill archives --- AGENTS.md | 7 +- README.md | 67 ++++---- bin/ink-skills | 371 +++++++++++++++++++++++------------------- test/install-smoke.sh | 98 ++++++----- 4 files changed, 297 insertions(+), 246 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc0d101..592dc95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ## Layout - `skills//SKILL.md` — one narrowly triggered on-demand behavior. -- `bin/ink-skills` — dependency-free installer; stdout is TSV, diagnostics stderr. +- `bin/ink-skills` — POSIX linker/importer; stdout is TSV, diagnostics stderr. - `test/install-smoke.sh` — installer contract smoke. ## Rules @@ -13,7 +13,8 @@ - A skill owns reusable judgment, never runtime policy or repeatable mechanics. - Ink source and `REQUIREMENTS.md` own host behavior; `toolset` owns external executables. - Keep skill names lowercase and hyphenated; directory and frontmatter name must match. -- The installer only creates symlinks and must refuse collisions. Do not add a registry, - network calls, package-manager dependency, prompts, copies, or hidden state. +- `link` only creates symlinks. `import` may materialize only a caller-supplied, + SHA-256-verified local archive in the content-addressed store. Do not add URL, + Git, credential, registry, package-manager, prompt, or updater behavior. - Run `sh -n bin/ink-skills`, `sh test/install-smoke.sh`, and `sh test/skills-smoke.sh` after changes. diff --git a/README.md b/README.md index 498f035..f352b3a 100644 --- a/README.md +++ b/README.md @@ -7,61 +7,58 @@ This repository owns reusable Ink judgment. Ink owns runtime enforcement and policy. [`toolset`](https://git.tmk241.com/tmk241/toolset) owns compiled external executables. Skills never grant authority by themselves. -## Install +## Link local skills -Clone once, then symlink the skills you want: +Clone once, then link every skill shipped by this checkout: ```sh git clone git@git.tmk241.com:tmk241/ink-skills.git -ink-skills/bin/ink-skills install +ink-skills/bin/ink-skills link ``` -If the repository is already under `/opt/repositories`: +Link selected directories or target one project: ```sh -/opt/repositories/ink-skills/bin/ink-skills install +ink-skills link /opt/repositories/ink-skills/skills/audit-ink-cli +ink-skills link --project /path/to/project skills/configure-ink-agent ``` -Install selected skills only: +`link` only creates absolute symlinks. It performs no network access, copies, +prompts, registry lookup, or policy mutation. Pulling a linked checkout changes +its bytes; restart Ink to freeze the updated skill snapshot. + +## Import verified artifacts + +Transport and authentication remain ordinary shell jobs: ```sh -ink-skills install audit-ink-cli configure-ink-agent +curl -fLo skills.tar https://example/skills.tar +git archive --format=tar HEAD >skills.tar ``` -Install into one project instead of the user catalogue: +The publisher communicates the expected digest out of band. Import only after you +have that value: ```sh -ink-skills install --project /path/to/project configure-ink-agent +ink-skills import sha256:012345... skills.tar +ink-skills import --project /path/to/project sha256:012345... skills.tar skills/review-sql ``` -Install from any Git repository your normal Git credentials can read: +`import` verifies the complete archive before extraction, accepts only regular +files and directories with safe relative paths, and rejects symlinks and special +files. It materializes the tree under +`$INK_SKILLS_STORE/sha256/HASH` (or the XDG/default data path), then links skills +from that immutable content-addressed location. `.ink-skills.tsv` records each +installed skill's archive digest and path without modifying `SKILL.md`. -```sh -ink-skills add --ref main git@git.example:team/skills.git -ink-skills add https://git.example/team/skills.git skills/review-sql -``` +The same artifact works whether it arrived via curl, scp, USB, a browser download, +or `git archive`. `ink-skills` deliberately has no URL, Git, credential, branch, +release, or update logic. -`add` resolves the ref to one commit, exports it into a content-addressed store, -computes a SHA-256 over each selected skill tree, and symlinks that immutable -artifact. Provenance is recorded next to installed links in `.ink-skills.tsv`: -source, requested ref, resolved commit, path, and SHA-256. It deliberately does -not edit `SKILL.md` comments. A moved branch does not silently update an installed -skill; the existing pin causes a visible provenance collision. - -Git SSH agents and credential helpers remain Git's job. Credential-bearing HTTP -URLs are refused so secrets do not enter manifests or process listings. Remote -skill trees containing symlinks are also refused. - -The local `install` path is intentionally smaller than `npx skills`: no registry, -package manager, network access, copies, prompts, lockfile, or hidden state. It -creates absolute symlinks from `$HOME/.ink/skills` (or the installer-only -`$INK_SKILLS_HOME` target override) to this checkout. Pulling the repository -updates locally installed skills; restarting Ink freezes the new bytes into the -next startup snapshot. Ink itself discovers `$HOME/.ink/skills`, -`$CWD/.ink/skills`, and colon-separated `INK_SKILLS_DIRS`. - -`ink-skills list` emits TSV. `ink-skills --help` is the complete command manual. -Existing paths and foreign symlinks are refused rather than overwritten. +Ink discovers `$HOME/.ink/skills`, `$CWD/.ink/skills`, and colon-separated +`INK_SKILLS_DIRS`. The installer-only `$INK_SKILLS_HOME` overrides the user link +target. `ink-skills list` emits TSV; `ink-skills --help` is the complete command +manual. Existing paths and foreign symlinks are refused rather than overwritten. ## Skills diff --git a/bin/ink-skills b/bin/ink-skills index b21fe3a..4157ad8 100755 --- a/bin/ink-skills +++ b/bin/ink-skills @@ -4,41 +4,41 @@ set -eu usage() { cat <<'EOF' usage: ink-skills list - ink-skills install [--user | --project DIR] [SKILL ...] - ink-skills add [--user | --project DIR] [--ref REF] REPOSITORY [PATH ...] + ink-skills link [--user | --project DIR] [SKILL_DIR ...] + ink-skills import [--user | --project DIR] sha256:HASH ARCHIVE [PATH ...] -Install Ink skills as symlinks from this checkout or a pinned Git artifact. +Link local Ink skills or import a verified skill archive. Commands: - list List skills in this checkout as TSV. - install Link named local skills; with no names, link every skill. - add Fetch REPOSITORY, pin REF to a commit, materialize an - immutable snapshot, and link skill PATHs from it. PATH - defaults to every skills/*/SKILL.md directory. + list List skills shipped by this checkout as TSV. + link Symlink local skill directories. With no directories, link every + skill shipped by this checkout. + import Verify ARCHIVE against the required SHA-256, safely materialize its + immutable tree, then link selected skill PATHs. PATH defaults to + every skills/*/SKILL.md directory in the archive. Targets: --user $INK_SKILLS_HOME or $HOME/.ink/skills (default) --project DIR DIR/.ink/skills -Git storage: +Artifact store: $INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise - $HOME/.local/share/ink-skills. Git, tar, and sha256sum are required by `add`. - Authentication belongs to Git's SSH agent or credential helper; credentialed - HTTP URLs are refused. Installed provenance is written to - TARGET/.ink-skills.tsv without modifying SKILL.md. + $HOME/.local/share/ink-skills. + +Transport is deliberately external: + curl -fLo skills.tar URL + ink-skills import sha256:HASH skills.tar Output: - TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns. + TSV with SKILL, TARGET, ACTION, SOURCE, and SHA256 columns. Exit status: - 0 success; 2 usage error; 3 collision, invalid source, or fetch failure. + 0 success; 2 usage error; 3 invalid skill, collision, digest mismatch, or + unsafe archive. -Examples: - ink-skills list - ink-skills install audit-ink-cli configure-ink-agent - ink-skills install --project . create-ink-tool - ink-skills add --ref main git@git.example:team/skills.git - ink-skills add https://git.example/team/skills.git skills/review-sql +Requirements: + POSIX sh and standard text tools. `import` additionally needs tar and one of + sha256sum, shasum, or openssl. EOF } @@ -47,6 +47,22 @@ die() { exit 3 } +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) +bundled_dir=$repo_dir/skills +cleanup_root= +cleanup_artifact= +cleanup_lock= +cleanup_all() { + if [ -n "$cleanup_artifact" ]; then + chmod -R u+w "$cleanup_artifact" 2>/dev/null || : + rm -rf "$cleanup_artifact" + fi + [ -z "$cleanup_lock" ] || rmdir "$cleanup_lock" 2>/dev/null || : + [ -z "$cleanup_root" ] || rm -rf "$cleanup_root" +} +trap cleanup_all EXIT HUP INT TERM + reject_record_breaks() { case $1 in *" "*|*" @@ -54,17 +70,20 @@ reject_record_breaks() { esac } -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) -repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) -skills_dir=$repo_dir/skills +frontmatter_name() { + sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p' +} -list_skills() { - printf 'SKILL\tSOURCE\n' - for path in "$skills_dir"/*; do - [ -d "$path" ] || continue - [ -f "$path/SKILL.md" ] || continue - printf '%s\t%s\n' "$(basename -- "$path")" "$path" - done +validate_skill_dir() { + skill_dir=$1 + [ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || die "not a skill directory: $skill_dir" + [ ! -L "$skill_dir" ] && [ ! -L "$skill_dir/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $skill_dir" + skill_name=$(frontmatter_name "$skill_dir") + [ -n "$skill_name" ] || die "missing frontmatter name: $skill_dir/SKILL.md" + [ "$skill_name" = "$(basename -- "$skill_dir")" ] || die "frontmatter name does not match directory: $skill_dir" + case $skill_name in + ''|.*|*/*) die "invalid skill name: $skill_name" ;; + esac } select_target() { @@ -85,48 +104,48 @@ select_target() { mkdir -p -- "$target" } -parse_target_option() { - case $1 in - --user) - target_mode=user - shift_count=1 - ;; - --project) - [ "$#" -ge 2 ] || { +parse_target_options() { + while [ "$#" -gt 0 ]; do + case $1 in + --user) + target_mode=user + shift + ;; + --project) + [ "$#" -ge 2 ] || { + usage >&2 + exit 2 + } + target_mode=project + target_arg=$2 + shift 2 + ;; + --) + shift + break + ;; + -*) usage >&2 exit 2 - } - target_mode=project - target_arg=$2 - shift_count=2 - ;; - *) shift_count=0 ;; - esac -} - -frontmatter_name() { - sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p' -} - -validate_skill_dir() { - source_path=$1 - [ -d "$source_path" ] && [ -f "$source_path/SKILL.md" ] || die "not a skill directory: $source_path" - [ ! -L "$source_path" ] && [ ! -L "$source_path/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $source_path" - skill_name=$(frontmatter_name "$source_path") - [ -n "$skill_name" ] || die "missing frontmatter name: $source_path/SKILL.md" - [ "$skill_name" = "$(basename -- "$source_path")" ] || die "frontmatter name does not match directory: $source_path" - case $skill_name in - ''|.*|*/*) die "invalid skill name: $skill_name" ;; - esac + ;; + *) break ;; + esac + done + remaining_count=$# + remaining_file=$work_args + : >"$remaining_file" + for arg do + reject_record_breaks "$arg" + printf '%s\n' "$arg" >>"$remaining_file" + done } link_skill() { - skill_name=$1 + name=$1 source_path=$2 source_label=$3 - commit=$4 - digest=$5 - destination=$target/$skill_name + digest=$4 + destination=$target/$name if [ -L "$destination" ]; then linked=$(readlink "$destination") [ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked" @@ -137,34 +156,58 @@ link_skill() { ln -s -- "$source_path" "$destination" action=linked fi - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$skill_name" "$destination" "$action" "$source_label" "$commit" "$digest" + printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$destination" "$action" "$source_label" "$digest" } -check_source_record() { - skill_name=$1 - source_label=$2 - ref=$3 - commit=$4 - skill_path=$5 - digest=$6 +manifest_check() { + name=$1 + digest=$2 + archive_path=$3 manifest=$target/.ink-skills.tsv record_needed=yes - [ -e "$manifest" ] || return 0 - if awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { found = 1 } END { exit !found }' "$manifest"; then - existing=$(awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { print $0; exit }' "$manifest") - wanted=$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$skill_name" "$source_label" "$ref" "$commit" "$skill_path" "$digest") - [ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $skill_name" + [ -f "$manifest" ] || return 0 + if awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { found = 1 } END { exit !found }' "$manifest"; then + existing=$(awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { print $0; exit }' "$manifest") + wanted=$(printf '%s\t%s\t%s' "$name" "$digest" "$archive_path") + [ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $name" record_needed=no fi } -append_source_record() { +manifest_append() { [ "$record_needed" = yes ] || return 0 manifest=$target/.ink-skills.tsv - if [ ! -e "$manifest" ]; then - printf 'SKILL\tSOURCE\tREF\tCOMMIT\tPATH\tSHA256\n' >"$manifest" + [ -e "$manifest" ] || printf 'SKILL\tSHA256\tPATH\n' >"$manifest" + printf '%s\t%s\t%s\n' "$name" "$digest" "$archive_path" >>"$manifest" +} + +sha256_file() { + file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$file" | sed 's/^.*= //' + else + die 'import requires sha256sum, shasum, or openssl' fi - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest" >>"$manifest" +} + +validate_archive_listing() { + archive=$1 + names=$2 + types=$3 + tar -tf "$archive" >"$names" || die "cannot list archive: $archive" + [ -s "$names" ] || die 'archive is empty' + while IFS= read -r member; do + reject_record_breaks "$member" + case $member in + ''|/*|..|../*|*/../*|*/..) die "unsafe archive path: $member" ;; + esac + done <"$names" + LC_ALL=C tar -tvf "$archive" >"$types" || die "cannot inspect archive: $archive" + awk 'substr($1, 1, 1) != "-" && substr($1, 1, 1) != "d" { exit 1 }' "$types" || die 'archive may contain only regular files and directories' } [ "$#" -gt 0 ] || { @@ -183,10 +226,14 @@ case $command in usage >&2 exit 2 } - list_skills + printf 'SKILL\tSOURCE\n' + for path in "$bundled_dir"/*; do + [ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue + printf '%s\t%s\n' "$(basename -- "$path")" "$path" + done exit 0 ;; - install|add) ;; + link|import) ;; *) usage >&2 exit 2 @@ -195,74 +242,57 @@ esac target_mode=user target_arg= -ref=HEAD -while [ "$#" -gt 0 ]; do - parse_target_option "$@" - if [ "$shift_count" -gt 0 ]; then - shift "$shift_count" - continue - fi - case $1 in - --ref) - [ "$command" = add ] && [ "$#" -ge 2 ] || { - usage >&2 - exit 2 - } - ref=$2 - shift 2 - ;; - --) - shift - break - ;; - -*) - usage >&2 - exit 2 - ;; - *) break ;; - esac -done +work_root=${TMPDIR:-/tmp}/ink-skills-args-$$ +(umask 077 && mkdir "$work_root") || die "cannot create temporary directory: $work_root" +cleanup_root=$work_root +work_args=$work_root/args +parse_target_options "$@" +set -- +while IFS= read -r arg; do set -- "$@" "$arg"; done <"$work_args" select_target -printf 'SKILL\tTARGET\tACTION\tSOURCE\tCOMMIT\tSHA256\n' +printf 'SKILL\tTARGET\tACTION\tSOURCE\tSHA256\n' -if [ "$command" = install ]; then +if [ "$command" = link ]; then if [ "$#" -eq 0 ]; then set -- - for path in "$skills_dir"/*; do - [ -d "$path" ] || continue - [ -f "$path/SKILL.md" ] || continue - set -- "$@" "$(basename -- "$path")" + for path in "$bundled_dir"/*; do + [ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue + set -- "$@" "$path" done fi - for skill do - case $skill in - ''|.*|*/*) die "invalid skill name: $skill" ;; + for source_path do + case $source_path in + /*) ;; + *) source_path=$(CDPATH= cd -- "$(dirname -- "$source_path")" 2>/dev/null && printf '%s/%s\n' "$PWD" "$(basename -- "$source_path")") || die "skill directory not found: $source_path" ;; esac - source_path=$skills_dir/$skill validate_skill_dir "$source_path" - link_skill "$skill_name" "$source_path" local - - + link_skill "$skill_name" "$source_path" local - done exit 0 fi -[ "$#" -gt 0 ] || { +[ "$#" -ge 2 ] || { usage >&2 exit 2 } -repository=$1 -shift -reject_record_breaks "$repository" -reject_record_breaks "$ref" -case $ref in - -*) die "invalid ref: $ref" ;; +digest_spec=$1 +archive=$2 +shift 2 +case $digest_spec in + sha256:*) digest=${digest_spec#sha256:} ;; + *) die 'digest must use sha256:HASH' ;; esac -case $repository in - http://*@*|https://*@*) die 'credentialed HTTP URLs are refused; use an SSH agent or Git credential helper' ;; +digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f') +case $digest in + *[!0-9a-f]*|'') die 'SHA-256 must contain 64 hexadecimal characters' ;; esac -command -v git >/dev/null 2>&1 || die 'git is required by add' -command -v tar >/dev/null 2>&1 || die 'tar is required by add' -command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required by add' +[ "${#digest}" -eq 64 ] || die 'SHA-256 must contain 64 hexadecimal characters' +[ -f "$archive" ] || die "archive not found: $archive" +command -v tar >/dev/null 2>&1 || die 'import requires tar' +actual=$(sha256_file "$archive") +actual=$(printf '%s' "$actual" | tr 'A-F' 'a-f') +[ "$actual" = "$digest" ] || die "SHA-256 mismatch: expected $digest, got $actual" if [ -n "${INK_SKILLS_STORE:-}" ]; then store=$INK_SKILLS_STORE @@ -272,48 +302,51 @@ else [ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE' store=$HOME/.local/share/ink-skills fi -repo_key=$(printf '%s' "$repository" | git hash-object --stdin) || die 'cannot hash repository identity' -mirror=$store/git/$repo_key.git -mkdir -p -- "$store/git" "$store/artifacts/$repo_key" -if [ ! -d "$mirror" ]; then - git clone --quiet --mirror -- "$repository" "$mirror" || die "cannot clone repository: $repository" -fi -git --git-dir="$mirror" fetch --quiet --force origin "$ref" || die "cannot fetch ref: $ref" -commit=$(git --git-dir="$mirror" rev-parse --verify 'FETCH_HEAD^{commit}') || die "ref does not resolve to a commit: $ref" -artifact=$store/artifacts/$repo_key/$commit -if [ ! -d "$artifact" ]; then - temporary=$artifact.tmp.$$ - rm -rf -- "$temporary" - mkdir -p -- "$temporary" - if ! git --git-dir="$mirror" archive "$commit" | tar -x -C "$temporary"; then - rm -rf -- "$temporary" - die "cannot materialize commit: $commit" +artifact=$store/sha256/$digest +tree=$artifact/tree +if [ -d "$artifact" ]; then + [ -f "$artifact/complete" ] && [ "$(cat "$artifact/complete")" = "$digest" ] || die "incomplete artifact store entry: $artifact" +else + mkdir -p -- "$store/sha256" + lock=$store/sha256/.$digest.lock + mkdir "$lock" 2>/dev/null || die "artifact import already in progress: $digest" + temporary=$store/sha256/.$digest.tmp.$$ + cleanup_lock=$lock + cleanup_artifact=$temporary + mkdir -p -- "$temporary/tree" + validate_archive_listing "$archive" "$work_root/names" "$work_root/types" + tar -xf "$archive" -C "$temporary/tree" || die "cannot extract archive: $archive" + if find "$temporary/tree" -type l -print | grep . >/dev/null 2>&1; then + die 'archive extracted symlinks' fi - mv -- "$temporary" "$artifact" + if find "$temporary/tree" ! -type d ! -type f -print | grep . >/dev/null 2>&1; then + die 'archive extracted non-file entries' + fi + printf '%s\n' "$digest" >"$temporary/complete" + chmod -R a-w "$temporary" + mv "$temporary" "$artifact" + cleanup_artifact= + rmdir "$lock" + cleanup_lock= fi if [ "$#" -eq 0 ]; then set -- - for source_path in "$artifact"/skills/*; do - [ -d "$source_path" ] || continue - [ -f "$source_path/SKILL.md" ] || continue - set -- "$@" "skills/$(basename -- "$source_path")" + for skill_dir in "$tree"/skills/*; do + [ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || continue + set -- "$@" "skills/$(basename -- "$skill_dir")" done - [ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit" + [ "$#" -gt 0 ] || die 'archive has no skills/*/SKILL.md directories' fi -for skill_path do - reject_record_breaks "$skill_path" - case $skill_path in - ''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;; +for archive_path do + case $archive_path in + ''|/*|*:*|..|../*|*/../*|*/..) die "invalid archive skill path: $archive_path" ;; esac - source_path=$artifact/$skill_path + source_path=$tree/$archive_path validate_skill_dir "$source_path" - if find "$source_path" -type l -print | grep . >/dev/null 2>&1; then - die "remote skill contains symlinks: $skill_path" - fi - digest=$(git --git-dir="$mirror" archive "$commit:$skill_path" | sha256sum | awk '{print $1}') || die "cannot hash skill artifact: $skill_path" - check_source_record "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest" - link_skill "$skill_name" "$source_path" "$repository" "$commit" "$digest" - append_source_record + name=$skill_name + manifest_check "$name" "$digest" "$archive_path" + link_skill "$name" "$source_path" artifact "$digest" + manifest_append done diff --git a/test/install-smoke.sh b/test/install-smoke.sh index 3a81648..9953582 100755 --- a/test/install-smoke.sh +++ b/test/install-smoke.sh @@ -3,7 +3,11 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) tmp=${TMPDIR:-/tmp}/ink-skills-smoke-$$ -trap 'rm -rf "$tmp"' EXIT HUP INT TERM +cleanup() { + chmod -R u+w "$tmp" 2>/dev/null || : + rm -rf "$tmp" +} +trap cleanup EXIT HUP INT TERM mkdir -p "$tmp/home" "$tmp/project" list=$($repo/bin/ink-skills list) @@ -11,28 +15,34 @@ printf '%s\n' "$list" | grep '^SKILL' >/dev/null printf '%s\n' "$list" | grep '^audit-ink-cli' >/dev/null printf '%s\n' "$list" | grep '^configure-ink-agent' >/dev/null -HOME=$tmp/home $repo/bin/ink-skills install audit-ink-cli >"$tmp/install.tsv" -grep "audit-ink-cli.*linked" "$tmp/install.tsv" >/dev/null +HOME=$tmp/home $repo/bin/ink-skills link >"$tmp/link.tsv" +grep "audit-ink-cli.*linked.*local" "$tmp/link.tsv" >/dev/null [ -L "$tmp/home/.ink/skills/audit-ink-cli" ] [ "$(readlink "$tmp/home/.ink/skills/audit-ink-cli")" = "$repo/skills/audit-ink-cli" ] -HOME=$tmp/home $repo/bin/ink-skills install audit-ink-cli >"$tmp/reinstall.tsv" -grep "audit-ink-cli.*unchanged" "$tmp/reinstall.tsv" >/dev/null +HOME=$tmp/home $repo/bin/ink-skills link "$repo/skills/audit-ink-cli" >"$tmp/relink.tsv" +grep "audit-ink-cli.*unchanged" "$tmp/relink.tsv" >/dev/null -mkdir -p "$tmp/home/.ink/skills/configure-ink-agent" -if HOME=$tmp/home $repo/bin/ink-skills install configure-ink-agent >/dev/null 2>"$tmp/collision.err"; then +mkdir -p "$tmp/home/.ink/skills/collision" +mkdir -p "$tmp/collision" +cat >"$tmp/collision/SKILL.md" <<'EOF' +--- +name: collision +description: Fixture. +--- +EOF +if HOME=$tmp/home $repo/bin/ink-skills link "$tmp/collision" >/dev/null 2>"$tmp/collision.err"; then echo 'expected collision refusal' >&2 exit 1 fi grep 'refusing existing path' "$tmp/collision.err" >/dev/null -HOME=$tmp/home $repo/bin/ink-skills install --project "$tmp/project" create-ink-tool >"$tmp/project.tsv" +HOME=$tmp/home $repo/bin/ink-skills link --project "$tmp/project" "$repo/skills/create-ink-tool" >"$tmp/project.tsv" [ -L "$tmp/project/.ink/skills/create-ink-tool" ] -grep "create-ink-tool.*linked" "$tmp/project.tsv" >/dev/null +grep "create-ink-tool.*linked.*local" "$tmp/project.tsv" >/dev/null -remote=$tmp/remote -mkdir -p "$remote/skills/remote-review" -cat >"$remote/skills/remote-review/SKILL.md" <<'EOF' +mkdir -p "$tmp/archive-tree/skills/remote-review" +cat >"$tmp/archive-tree/skills/remote-review/SKILL.md" <<'EOF' --- name: remote-review description: Review one remote fixture. @@ -40,43 +50,53 @@ description: Review one remote fixture. # Remote review EOF -git -C "$remote" init -q -git -C "$remote" config user.email ink-skills@example.invalid -git -C "$remote" config user.name 'Ink Skills Test' -git -C "$remote" add skills/remote-review/SKILL.md -git -C "$remote" commit -qm fixture -commit=$(git -C "$remote" rev-parse HEAD) +tar -cf "$tmp/skills.tar" -C "$tmp/archive-tree" skills +digest=$(sha256sum "$tmp/skills.tar" | awk '{print $1}') -mkdir -p "$tmp/remote-home" -HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/add.tsv" -remote_link=$tmp/remote-home/.ink/skills/remote-review +mkdir -p "$tmp/import-home" +HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$digest" "$tmp/skills.tar" >"$tmp/import.tsv" +remote_link=$tmp/import-home/.ink/skills/remote-review [ -L "$remote_link" ] -case $(readlink "$remote_link") in - "$tmp/store/artifacts/"*"/$commit/skills/remote-review") ;; - *) echo 'remote link is not pinned to resolved commit' >&2; exit 1 ;; -esac -grep "remote-review.*linked.*$commit" "$tmp/add.tsv" >/dev/null -manifest=$tmp/remote-home/.ink/skills/.ink-skills.tsv +[ "$(readlink "$remote_link")" = "$tmp/store/sha256/$digest/tree/skills/remote-review" ] +grep "remote-review.*linked.*artifact.*$digest" "$tmp/import.tsv" >/dev/null +manifest=$tmp/import-home/.ink/skills/.ink-skills.tsv [ "$(wc -l <"$manifest")" -eq 2 ] -awk -F '\t' -v commit="$commit" 'NR == 2 { exit !($1 == "remote-review" && $4 == commit && length($6) == 64) }' "$manifest" +awk -F '\t' -v digest="$digest" 'NR == 2 { exit !($1 == "remote-review" && $2 == digest && $3 == "skills/remote-review") }' "$manifest" -HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/readd.tsv" -grep 'remote-review.*unchanged' "$tmp/readd.tsv" >/dev/null +HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$digest" "$tmp/skills.tar" skills/remote-review >"$tmp/reimport.tsv" +grep 'remote-review.*unchanged' "$tmp/reimport.tsv" >/dev/null [ "$(wc -l <"$manifest")" -eq 2 ] -printf '\nchanged\n' >>"$remote/skills/remote-review/SKILL.md" -git -C "$remote" add skills/remote-review/SKILL.md -git -C "$remote" commit -qm changed -if HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >/dev/null 2>"$tmp/pin.err"; then - echo 'expected provenance collision after ref moves' >&2 +zero_digest=$(awk 'BEGIN { for (i = 0; i < 64; i++) printf "0" }') +if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$zero_digest" "$tmp/skills.tar" >/dev/null 2>"$tmp/digest.err"; then + echo 'expected digest mismatch' >&2 exit 1 fi -grep 'provenance collision' "$tmp/pin.err" >/dev/null +grep 'SHA-256 mismatch' "$tmp/digest.err" >/dev/null -if HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add 'https://user:secret@example.invalid/skills.git' >/dev/null 2>"$tmp/credential.err"; then - echo 'expected credentialed URL refusal' >&2 +printf '\nchanged\n' >>"$tmp/archive-tree/skills/remote-review/SKILL.md" +tar -cf "$tmp/changed.tar" -C "$tmp/archive-tree" skills +changed_digest=$(sha256sum "$tmp/changed.tar" | awk '{print $1}') +if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$changed_digest" "$tmp/changed.tar" >/dev/null 2>"$tmp/provenance.err"; then + echo 'expected provenance collision after artifact changes' >&2 exit 1 fi -grep 'credentialed HTTP URLs are refused' "$tmp/credential.err" >/dev/null +grep 'provenance collision' "$tmp/provenance.err" >/dev/null + +mkdir -p "$tmp/unsafe/skills/unsafe" +cat >"$tmp/unsafe/skills/unsafe/SKILL.md" <<'EOF' +--- +name: unsafe +description: Unsafe fixture. +--- +EOF +ln -s /etc/passwd "$tmp/unsafe/skills/unsafe/passwd" +tar -cf "$tmp/unsafe.tar" -C "$tmp/unsafe" skills +unsafe_digest=$(sha256sum "$tmp/unsafe.tar" | awk '{print $1}') +if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$unsafe_digest" "$tmp/unsafe.tar" >/dev/null 2>"$tmp/unsafe.err"; then + echo 'expected symlink archive refusal' >&2 + exit 1 +fi +grep 'regular files and directories' "$tmp/unsafe.err" >/dev/null printf 'ok\n'