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
- `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.
## 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.
+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
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
+190 -157
View File
@@ -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,11 +104,12 @@ select_target() {
mkdir -p -- "$target"
}
parse_target_option() {
parse_target_options() {
while [ "$#" -gt 0 ]; do
case $1 in
--user)
target_mode=user
shift_count=1
shift
;;
--project)
[ "$#" -ge 2 ] || {
@@ -98,35 +118,34 @@ parse_target_option() {
}
target_mode=project
target_arg=$2
shift_count=2
shift 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" ;;
--)
shift
break
;;
-*)
usage >&2
exit 2
;;
*) 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"
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
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"
if find "$temporary/tree" ! -type d ! -type f -print | grep . >/dev/null 2>&1; then
die 'archive extracted non-file entries'
fi
mv -- "$temporary" "$artifact"
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
+59 -39
View File
@@ -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'