Import verified skill archives

This commit is contained in:
tmk241
2026-08-11 17:04:37 +02:00
parent efc4b70e13
commit 8547d6ea33
4 changed files with 297 additions and 246 deletions
+4 -3
View File
@@ -5,7 +5,7 @@
## Layout ## Layout
- `skills/<name>/SKILL.md` — one narrowly triggered on-demand behavior. - `skills/<name>/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. - `test/install-smoke.sh` — installer contract smoke.
## Rules ## Rules
@@ -13,7 +13,8 @@
- A skill owns reusable judgment, never runtime policy or repeatable mechanics. - A skill owns reusable judgment, never runtime policy or repeatable mechanics.
- Ink source and `REQUIREMENTS.md` own host behavior; `toolset` owns external executables. - Ink source and `REQUIREMENTS.md` own host behavior; `toolset` owns external executables.
- Keep skill names lowercase and hyphenated; directory and frontmatter name must match. - 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, - `link` only creates symlinks. `import` may materialize only a caller-supplied,
network calls, package-manager dependency, prompts, copies, or hidden state. 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 - Run `sh -n bin/ink-skills`, `sh test/install-smoke.sh`, and
`sh test/skills-smoke.sh` after changes. `sh test/skills-smoke.sh` after changes.
+32 -35
View File
@@ -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 policy. [`toolset`](https://git.tmk241.com/tmk241/toolset) owns compiled external
executables. Skills never grant authority by themselves. 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 ```sh
git clone git@git.tmk241.com:tmk241/ink-skills.git 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 ```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 ```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 ```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 The same artifact works whether it arrived via curl, scp, USB, a browser download,
ink-skills add --ref main git@git.example:team/skills.git or `git archive`. `ink-skills` deliberately has no URL, Git, credential, branch,
ink-skills add https://git.example/team/skills.git skills/review-sql release, or update logic.
```
`add` resolves the ref to one commit, exports it into a content-addressed store, Ink discovers `$HOME/.ink/skills`, `$CWD/.ink/skills`, and colon-separated
computes a SHA-256 over each selected skill tree, and symlinks that immutable `INK_SKILLS_DIRS`. The installer-only `$INK_SKILLS_HOME` overrides the user link
artifact. Provenance is recorded next to installed links in `.ink-skills.tsv`: target. `ink-skills list` emits TSV; `ink-skills --help` is the complete command
source, requested ref, resolved commit, path, and SHA-256. It deliberately does manual. Existing paths and foreign symlinks are refused rather than overwritten.
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.
## Skills ## Skills
+202 -169
View File
@@ -4,41 +4,41 @@ set -eu
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: ink-skills list usage: ink-skills list
ink-skills install [--user | --project DIR] [SKILL ...] ink-skills link [--user | --project DIR] [SKILL_DIR ...]
ink-skills add [--user | --project DIR] [--ref REF] REPOSITORY [PATH ...] 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: Commands:
list List skills in this checkout as TSV. list List skills shipped by this checkout as TSV.
install Link named local skills; with no names, link every skill. link Symlink local skill directories. With no directories, link every
add Fetch REPOSITORY, pin REF to a commit, materialize an skill shipped by this checkout.
immutable snapshot, and link skill PATHs from it. PATH import Verify ARCHIVE against the required SHA-256, safely materialize its
defaults to every skills/*/SKILL.md directory. immutable tree, then link selected skill PATHs. PATH defaults to
every skills/*/SKILL.md directory in the archive.
Targets: Targets:
--user $INK_SKILLS_HOME or $HOME/.ink/skills (default) --user $INK_SKILLS_HOME or $HOME/.ink/skills (default)
--project DIR DIR/.ink/skills --project DIR DIR/.ink/skills
Git storage: Artifact store:
$INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise $INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise
$HOME/.local/share/ink-skills. Git, tar, and sha256sum are required by `add`. $HOME/.local/share/ink-skills.
Authentication belongs to Git's SSH agent or credential helper; credentialed
HTTP URLs are refused. Installed provenance is written to Transport is deliberately external:
TARGET/.ink-skills.tsv without modifying SKILL.md. curl -fLo skills.tar URL
ink-skills import sha256:HASH skills.tar
Output: Output:
TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns. TSV with SKILL, TARGET, ACTION, SOURCE, and SHA256 columns.
Exit status: 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: Requirements:
ink-skills list POSIX sh and standard text tools. `import` additionally needs tar and one of
ink-skills install audit-ink-cli configure-ink-agent sha256sum, shasum, or openssl.
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
EOF EOF
} }
@@ -47,6 +47,22 @@ die() {
exit 3 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() { reject_record_breaks() {
case $1 in case $1 in
*" "*|*" *" "*|*"
@@ -54,17 +70,20 @@ reject_record_breaks() {
esac esac
} }
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) frontmatter_name() {
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p'
skills_dir=$repo_dir/skills }
list_skills() { validate_skill_dir() {
printf 'SKILL\tSOURCE\n' skill_dir=$1
for path in "$skills_dir"/*; do [ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || die "not a skill directory: $skill_dir"
[ -d "$path" ] || continue [ ! -L "$skill_dir" ] && [ ! -L "$skill_dir/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $skill_dir"
[ -f "$path/SKILL.md" ] || continue skill_name=$(frontmatter_name "$skill_dir")
printf '%s\t%s\n' "$(basename -- "$path")" "$path" [ -n "$skill_name" ] || die "missing frontmatter name: $skill_dir/SKILL.md"
done [ "$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() { select_target() {
@@ -85,48 +104,48 @@ select_target() {
mkdir -p -- "$target" mkdir -p -- "$target"
} }
parse_target_option() { parse_target_options() {
case $1 in while [ "$#" -gt 0 ]; do
--user) case $1 in
target_mode=user --user)
shift_count=1 target_mode=user
;; shift
--project) ;;
[ "$#" -ge 2 ] || { --project)
[ "$#" -ge 2 ] || {
usage >&2
exit 2
}
target_mode=project
target_arg=$2
shift 2
;;
--)
shift
break
;;
-*)
usage >&2 usage >&2
exit 2 exit 2
} ;;
target_mode=project *) break ;;
target_arg=$2 esac
shift_count=2 done
;; remaining_count=$#
*) shift_count=0 ;; remaining_file=$work_args
esac : >"$remaining_file"
} for arg do
reject_record_breaks "$arg"
frontmatter_name() { printf '%s\n' "$arg" >>"$remaining_file"
sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p' done
}
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
} }
link_skill() { link_skill() {
skill_name=$1 name=$1
source_path=$2 source_path=$2
source_label=$3 source_label=$3
commit=$4 digest=$4
digest=$5 destination=$target/$name
destination=$target/$skill_name
if [ -L "$destination" ]; then if [ -L "$destination" ]; then
linked=$(readlink "$destination") linked=$(readlink "$destination")
[ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked" [ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked"
@@ -137,34 +156,58 @@ link_skill() {
ln -s -- "$source_path" "$destination" ln -s -- "$source_path" "$destination"
action=linked action=linked
fi 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() { manifest_check() {
skill_name=$1 name=$1
source_label=$2 digest=$2
ref=$3 archive_path=$3
commit=$4
skill_path=$5
digest=$6
manifest=$target/.ink-skills.tsv manifest=$target/.ink-skills.tsv
record_needed=yes record_needed=yes
[ -e "$manifest" ] || return 0 [ -f "$manifest" ] || return 0
if awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { found = 1 } END { exit !found }' "$manifest"; then if awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { found = 1 } END { exit !found }' "$manifest"; then
existing=$(awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { print $0; exit }' "$manifest") existing=$(awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { 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") wanted=$(printf '%s\t%s\t%s' "$name" "$digest" "$archive_path")
[ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $skill_name" [ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $name"
record_needed=no record_needed=no
fi fi
} }
append_source_record() { manifest_append() {
[ "$record_needed" = yes ] || return 0 [ "$record_needed" = yes ] || return 0
manifest=$target/.ink-skills.tsv manifest=$target/.ink-skills.tsv
if [ ! -e "$manifest" ]; then [ -e "$manifest" ] || printf 'SKILL\tSHA256\tPATH\n' >"$manifest"
printf 'SKILL\tSOURCE\tREF\tCOMMIT\tPATH\tSHA256\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 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 ] || { [ "$#" -gt 0 ] || {
@@ -183,10 +226,14 @@ case $command in
usage >&2 usage >&2
exit 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 exit 0
;; ;;
install|add) ;; link|import) ;;
*) *)
usage >&2 usage >&2
exit 2 exit 2
@@ -195,74 +242,57 @@ esac
target_mode=user target_mode=user
target_arg= target_arg=
ref=HEAD work_root=${TMPDIR:-/tmp}/ink-skills-args-$$
while [ "$#" -gt 0 ]; do (umask 077 && mkdir "$work_root") || die "cannot create temporary directory: $work_root"
parse_target_option "$@" cleanup_root=$work_root
if [ "$shift_count" -gt 0 ]; then work_args=$work_root/args
shift "$shift_count" parse_target_options "$@"
continue set --
fi while IFS= read -r arg; do set -- "$@" "$arg"; done <"$work_args"
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
select_target 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 if [ "$#" -eq 0 ]; then
set -- set --
for path in "$skills_dir"/*; do for path in "$bundled_dir"/*; do
[ -d "$path" ] || continue [ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue
[ -f "$path/SKILL.md" ] || continue set -- "$@" "$path"
set -- "$@" "$(basename -- "$path")"
done done
fi fi
for skill do for source_path do
case $skill in case $source_path in
''|.*|*/*) die "invalid skill name: $skill" ;; /*) ;;
*) 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 esac
source_path=$skills_dir/$skill
validate_skill_dir "$source_path" validate_skill_dir "$source_path"
link_skill "$skill_name" "$source_path" local - - link_skill "$skill_name" "$source_path" local -
done done
exit 0 exit 0
fi fi
[ "$#" -gt 0 ] || { [ "$#" -ge 2 ] || {
usage >&2 usage >&2
exit 2 exit 2
} }
repository=$1 digest_spec=$1
shift archive=$2
reject_record_breaks "$repository" shift 2
reject_record_breaks "$ref" case $digest_spec in
case $ref in sha256:*) digest=${digest_spec#sha256:} ;;
-*) die "invalid ref: $ref" ;; *) die 'digest must use sha256:HASH' ;;
esac esac
case $repository in digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f')
http://*@*|https://*@*) die 'credentialed HTTP URLs are refused; use an SSH agent or Git credential helper' ;; case $digest in
*[!0-9a-f]*|'') die 'SHA-256 must contain 64 hexadecimal characters' ;;
esac esac
command -v git >/dev/null 2>&1 || die 'git is required by add' [ "${#digest}" -eq 64 ] || die 'SHA-256 must contain 64 hexadecimal characters'
command -v tar >/dev/null 2>&1 || die 'tar is required by add' [ -f "$archive" ] || die "archive not found: $archive"
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required by add' 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 if [ -n "${INK_SKILLS_STORE:-}" ]; then
store=$INK_SKILLS_STORE store=$INK_SKILLS_STORE
@@ -272,48 +302,51 @@ else
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE' [ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE'
store=$HOME/.local/share/ink-skills store=$HOME/.local/share/ink-skills
fi fi
repo_key=$(printf '%s' "$repository" | git hash-object --stdin) || die 'cannot hash repository identity' artifact=$store/sha256/$digest
mirror=$store/git/$repo_key.git tree=$artifact/tree
mkdir -p -- "$store/git" "$store/artifacts/$repo_key" if [ -d "$artifact" ]; then
if [ ! -d "$mirror" ]; then [ -f "$artifact/complete" ] && [ "$(cat "$artifact/complete")" = "$digest" ] || die "incomplete artifact store entry: $artifact"
git clone --quiet --mirror -- "$repository" "$mirror" || die "cannot clone repository: $repository" else
fi mkdir -p -- "$store/sha256"
git --git-dir="$mirror" fetch --quiet --force origin "$ref" || die "cannot fetch ref: $ref" lock=$store/sha256/.$digest.lock
commit=$(git --git-dir="$mirror" rev-parse --verify 'FETCH_HEAD^{commit}') || die "ref does not resolve to a commit: $ref" mkdir "$lock" 2>/dev/null || die "artifact import already in progress: $digest"
artifact=$store/artifacts/$repo_key/$commit temporary=$store/sha256/.$digest.tmp.$$
if [ ! -d "$artifact" ]; then cleanup_lock=$lock
temporary=$artifact.tmp.$$ cleanup_artifact=$temporary
rm -rf -- "$temporary" mkdir -p -- "$temporary/tree"
mkdir -p -- "$temporary" validate_archive_listing "$archive" "$work_root/names" "$work_root/types"
if ! git --git-dir="$mirror" archive "$commit" | tar -x -C "$temporary"; then tar -xf "$archive" -C "$temporary/tree" || die "cannot extract archive: $archive"
rm -rf -- "$temporary" if find "$temporary/tree" -type l -print | grep . >/dev/null 2>&1; then
die "cannot materialize commit: $commit" die 'archive extracted symlinks'
fi 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 fi
if [ "$#" -eq 0 ]; then if [ "$#" -eq 0 ]; then
set -- set --
for source_path in "$artifact"/skills/*; do for skill_dir in "$tree"/skills/*; do
[ -d "$source_path" ] || continue [ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || continue
[ -f "$source_path/SKILL.md" ] || continue set -- "$@" "skills/$(basename -- "$skill_dir")"
set -- "$@" "skills/$(basename -- "$source_path")"
done done
[ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit" [ "$#" -gt 0 ] || die 'archive has no skills/*/SKILL.md directories'
fi fi
for skill_path do for archive_path do
reject_record_breaks "$skill_path" case $archive_path in
case $skill_path in ''|/*|*:*|..|../*|*/../*|*/..) die "invalid archive skill path: $archive_path" ;;
''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;;
esac esac
source_path=$artifact/$skill_path source_path=$tree/$archive_path
validate_skill_dir "$source_path" validate_skill_dir "$source_path"
if find "$source_path" -type l -print | grep . >/dev/null 2>&1; then name=$skill_name
die "remote skill contains symlinks: $skill_path" manifest_check "$name" "$digest" "$archive_path"
fi link_skill "$name" "$source_path" artifact "$digest"
digest=$(git --git-dir="$mirror" archive "$commit:$skill_path" | sha256sum | awk '{print $1}') || die "cannot hash skill artifact: $skill_path" manifest_append
check_source_record "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest"
link_skill "$skill_name" "$source_path" "$repository" "$commit" "$digest"
append_source_record
done done
+59 -39
View File
@@ -3,7 +3,11 @@ set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
tmp=${TMPDIR:-/tmp}/ink-skills-smoke-$$ 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" mkdir -p "$tmp/home" "$tmp/project"
list=$($repo/bin/ink-skills list) 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 '^audit-ink-cli' >/dev/null
printf '%s\n' "$list" | grep '^configure-ink-agent' >/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" HOME=$tmp/home $repo/bin/ink-skills link >"$tmp/link.tsv"
grep "audit-ink-cli.*linked" "$tmp/install.tsv" >/dev/null grep "audit-ink-cli.*linked.*local" "$tmp/link.tsv" >/dev/null
[ -L "$tmp/home/.ink/skills/audit-ink-cli" ] [ -L "$tmp/home/.ink/skills/audit-ink-cli" ]
[ "$(readlink "$tmp/home/.ink/skills/audit-ink-cli")" = "$repo/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" HOME=$tmp/home $repo/bin/ink-skills link "$repo/skills/audit-ink-cli" >"$tmp/relink.tsv"
grep "audit-ink-cli.*unchanged" "$tmp/reinstall.tsv" >/dev/null grep "audit-ink-cli.*unchanged" "$tmp/relink.tsv" >/dev/null
mkdir -p "$tmp/home/.ink/skills/configure-ink-agent" mkdir -p "$tmp/home/.ink/skills/collision"
if HOME=$tmp/home $repo/bin/ink-skills install configure-ink-agent >/dev/null 2>"$tmp/collision.err"; then 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 echo 'expected collision refusal' >&2
exit 1 exit 1
fi fi
grep 'refusing existing path' "$tmp/collision.err" >/dev/null 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" ] [ -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 "$tmp/archive-tree/skills/remote-review"
mkdir -p "$remote/skills/remote-review" cat >"$tmp/archive-tree/skills/remote-review/SKILL.md" <<'EOF'
cat >"$remote/skills/remote-review/SKILL.md" <<'EOF'
--- ---
name: remote-review name: remote-review
description: Review one remote fixture. description: Review one remote fixture.
@@ -40,43 +50,53 @@ description: Review one remote fixture.
# Remote review # Remote review
EOF EOF
git -C "$remote" init -q tar -cf "$tmp/skills.tar" -C "$tmp/archive-tree" skills
git -C "$remote" config user.email ink-skills@example.invalid digest=$(sha256sum "$tmp/skills.tar" | awk '{print $1}')
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)
mkdir -p "$tmp/remote-home" mkdir -p "$tmp/import-home"
HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/add.tsv" 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/remote-home/.ink/skills/remote-review remote_link=$tmp/import-home/.ink/skills/remote-review
[ -L "$remote_link" ] [ -L "$remote_link" ]
case $(readlink "$remote_link") in [ "$(readlink "$remote_link")" = "$tmp/store/sha256/$digest/tree/skills/remote-review" ]
"$tmp/store/artifacts/"*"/$commit/skills/remote-review") ;; grep "remote-review.*linked.*artifact.*$digest" "$tmp/import.tsv" >/dev/null
*) echo 'remote link is not pinned to resolved commit' >&2; exit 1 ;; manifest=$tmp/import-home/.ink/skills/.ink-skills.tsv
esac
grep "remote-review.*linked.*$commit" "$tmp/add.tsv" >/dev/null
manifest=$tmp/remote-home/.ink/skills/.ink-skills.tsv
[ "$(wc -l <"$manifest")" -eq 2 ] [ "$(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" 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/readd.tsv" >/dev/null grep 'remote-review.*unchanged' "$tmp/reimport.tsv" >/dev/null
[ "$(wc -l <"$manifest")" -eq 2 ] [ "$(wc -l <"$manifest")" -eq 2 ]
printf '\nchanged\n' >>"$remote/skills/remote-review/SKILL.md" zero_digest=$(awk 'BEGIN { for (i = 0; i < 64; i++) printf "0" }')
git -C "$remote" add skills/remote-review/SKILL.md 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
git -C "$remote" commit -qm changed echo 'expected digest mismatch' >&2
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
exit 1 exit 1
fi 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 printf '\nchanged\n' >>"$tmp/archive-tree/skills/remote-review/SKILL.md"
echo 'expected credentialed URL refusal' >&2 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 exit 1
fi 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' printf 'ok\n'