Compare commits

...

3 Commits

Author SHA1 Message Date
tmk241 92a2849376 align skills with additive project policy 2026-08-16 22:14:57 +02:00
tmk241 f6d0efdfc0 Align Ink skills with frozen delegation contracts 2026-08-12 01:05:49 +02:00
tmk241 8547d6ea33 Import verified skill archives 2026-08-11 17:04:37 +02:00
8 changed files with 346 additions and 277 deletions
+5 -4
View File
@@ -5,15 +5,16 @@
## 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
- 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 `SPEC.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.
+38 -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
@@ -88,6 +85,12 @@ Implement the bounded frontend task and return proof.
`access: read|write` selects reader/writer scheduling; it does not grant commands
or tools. Definitions are frozen at startup, so restart Ink after changing one.
Approved rows from `$HOME/.ink/policy` and ancestor project `.ink/policy` files are
additive alternatives. A project file can introduce a command only through the
normalized effective-policy digest approval; it is never trusted merely because it
exists. Empty project policy adds nothing. Child/session policy and the immutable
host floor may only narrow the approved durable rows.
Current caveat: Ink records `policy:` as frozen metadata but does not yet read the
named relative file into the child effective policy. Do not treat it as enforced.
Use the `configure-ink-agent` skill for the exact boundary and blocker.
+191 -158
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"
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
+9 -7
View File
@@ -37,14 +37,16 @@ changes belong to Ink's repository authority and implementation workflow.
- A human/operator may invoke Ink outside the governed agent. Give a copyable
operator command only when the user asks and its public help contract is proven.
- Use user-provided runtime output as runtime evidence. Otherwise inspect the
installed artifact identity and a demonstrably matching checkout's requirements,
installed artifact identity and a demonstrably matching checkout's specification,
tests, public help text, and source; label those findings as contract/source
evidence rather than executed runtime proof.
- Global and project policy files explain only their contribution. Effective
authority also depends on all policy layers, pinned executable and contract
bytes, startup freezing, and session decisions.
- Approved global and ancestor-project rows are additive alternatives; each file
explains only its contribution. Effective authority also depends on the approved
normalized digest, restrictive child/session policy, the host floor, pinned
executable and contract bytes, startup freezing, and session decisions. Never
interpret a project file as automatically trusted.
- Treat handovers, READMEs, examples, hidden source branches, and remembered argv
as leads. Public help owns operator-facing commands; requirements own intended
as leads. Public help owns operator-facing commands; the specification owns intended
behavior; tests and source establish current checkout behavior.
Distinguish three surfaces explicitly:
@@ -71,7 +73,7 @@ the frozen `tool delegate` subject.
**source-confirmed**, **missing**, **stale claim**, or **not proven**.
4. For an operator action, explain that the user—not the hosted agent—must run it.
Do not inspect or mutate session state as a substitute.
5. For a missing capability, require Ink's requirements authority before source
5. For a missing capability, require Ink's specification authority before source
implementation. Do not model it as a new external executable merely to bypass
the host boundary.
@@ -93,7 +95,7 @@ the frozen `tool delegate` subject.
EVIDENCE: <runtime output, installed artifact, matching source, or limitation>
SURFACE: <operator CLI, model built-in, or admitted external command>
FINDING: <observed/source-confirmed/missing/stale/not proven>
ACTION: <operator step, requirements step, or none>
ACTION: <operator step, specification step, or none>
```
Positive smoke: “Does Ink have a sessions command, and why can’t you run it?”
+6 -5
View File
@@ -63,7 +63,7 @@ no ancestor-chain project-agent discovery.
- `read` children may overlap and receive an immutable host floor with no command,
file-mutation, lifecycle-mutation, or delegation authority.
- `write` children are exclusive, operate in the canonical parent cwd, pause
parent effects, and still receive only their effective frozen policy.
parent mutations, and still receive only their effective frozen policy.
Never infer effective authority from `access`, prompt text, or a `policy:` label.
Use the child policy snapshot and a real allowed/denied smoke.
@@ -83,11 +83,12 @@ policy. Therefore:
those are already sufficient.
This gate may be removed only after the provider launch path proves that the
referenced bytes are pinned and conjoined into the child effective policy.
referenced bytes are pinned and applied as a restriction to the child's inherited
approved durable policy.
## Decision loop
1. Choose `read` unless the child must produce a real effect.
1. Choose `read` unless the child must perform a real mutation.
2. Choose the smallest model alias that fits the specialist job.
3. Put the definition in user scope or exact project cwd according to intended
precedence.
@@ -107,8 +108,8 @@ referenced bytes are pinned and conjoined into the child effective policy.
## Behavior smoke
Positive: “Create a frontend writer child with only formatter and file mutation
authority” loads this skill and blocks until the named policy is actually
conjoined or the parent frozen policy already supplies that exact boundary.
authority” loads this skill and blocks until the named restrictive policy is
actually enforced or the parent frozen policy already supplies that exact boundary.
Negative: “Ask the existing reviewer to inspect this diff” does not load this
skill; it is ordinary delegation.
+20 -18
View File
@@ -13,7 +13,7 @@ description: >-
## One job
Design a **permission-sized executable** whose name and argv expose its reachable
effects so Ink can discover, digest-pin, and grant it without granting a platform.
reads and mutations so Ink can discover, digest-pin, and grant it without granting a platform.
One executable need not mean one source file: share private build-time modules
when that does not widen runtime authority.
@@ -21,26 +21,26 @@ when that does not widen runtime authority.
Read only what can change the boundary:
- the exact job and every reachable side effect;
- Ink's current requirements, policy grammar, and mutation protocol;
- the exact job and every reachable mutation;
- Ink's current specification, policy grammar, and mutation protocol;
- neighboring tools and existing executables that may already satisfy the job;
- resource, credential, selector, target, packaging, and proof contracts;
- repository requirements and tests.
- repository specification and tests.
Project authority outranks this skill. Missing selector semantics, credentials,
recovery rules, or external contracts are blockers—not adapter opportunities.
```text
JOB -> REUSE? -> EFFECTS -> BOUNDARY -> CONTRACT -> ARTIFACT -> PROOF
JOB -> REUSE? -> READS/MUTATIONS -> BOUNDARY -> CONTRACT -> ARTIFACT -> PROOF
```
1. Reuse a directly inspectable executable only when granting its whole reachable
surface is honest; otherwise build the coherent missing boundary, not a wrapper.
2. Enumerate reads, mutations, network effects, secrets, state, children, and
2. Enumerate reads, mutations, network calls, secrets, state, children, and
config/plugin discovery; split where approval, blast radius, or recovery differ.
3. Define argv, selectors, streams, errors, dependencies, exhaustive versus
bounded output, and any tool-owned semantic presentation without recreating
shell grammar or a host-wide effect ontology.
shell grammar or a host-wide mutation ontology.
4. State the runtime artifact honestly, then falsify its boundary, behavior,
presentation, and portability claims through Ink's real run entry point.
@@ -108,7 +108,7 @@ Shell globs, path filters, and content patterns are different contracts.
`find`/`fd` forms that execute or delete and `rg` forms that launch preprocessors
are not read boundaries merely because one intended invocation only searches. A
narrow native search tool is justified when it removes reachable effects, adds
narrow native search tool is justified when it removes reachable mutations, adds
canonical path selectors, or supplies the required static portable artifact.
Implement the coherent missing subset, not a compatibility facade or renamed
wrapper.
@@ -125,8 +125,8 @@ tool apply <id> <hash> # revalidate; perform once
Required properties:
- `stage` performs no external effect and resolves no secrets;
- the manifest pins executable identity, canonical effect, authority subject,
- `stage` performs no mutation and resolves no secrets;
- the manifest pins executable identity, canonical mutation, authority subject,
non-secret inputs, and drift-sensitive hashes;
- selectors are variable semantic facts, never argv prefixes, shell text, or
executable invariants;
@@ -149,7 +149,7 @@ layout; the tool owns meaning.
Use the repository's elected projection envelope and bounds exactly; never invent
per-tool presentation formats. Within that contract, use a small display
vocabulary rather than universal effect kinds:
vocabulary rather than universal mutation kinds:
- headline and canonical target;
- ordered key/value facts;
@@ -159,7 +159,7 @@ vocabulary rather than universal effect kinds:
Filesystem, HTTP, cloud, and infrastructure tools express their own semantics
with those primitives. For example, an HTTP mutator supplies method, canonical
origin/path, bounded body summary, status, and final URL as facts; it does not ask
Ink to understand an `http` effect type. An infrastructure tool supplies account,
Ink to understand an `http` mutation type. An infrastructure tool supplies account,
resource, region, and requested change as facts; it does not create a renderer
branch for its provider.
@@ -169,7 +169,7 @@ The projection is presentation evidence, never authority:
- derive projection records purely from that value and hash their exact semantic
bytes with the manifest;
- render approval from the exact staged projection stored under that identity;
- apply accepts only staged id plus hash, never replacement target, body, effect,
- apply accepts only staged id plus hash, never replacement target, body, mutation,
or projection inputs;
- the receipt identifies the exact staged manifest and may add only outcome and
evidence facts; it cannot rewrite approved records;
@@ -189,12 +189,14 @@ otherwise expose manifests, hashes, encoded payloads, or provider internals.
## CLI and stream contract
`tool --help` is the tested human contract: effects, argv, streams, ordering,
`tool --help` is the tested human contract: mutations, argv, streams, ordering,
exits, environment/config precedence, credential timing, dependencies, pattern
semantics, and one realistic pipeline. Explicit help succeeds on stdout; usage
errors fail on stderr. Selector help gives value grammar, canonicalization, and a
least-authority policy row, including AND within one row and alternatives across
rows.
approved global and ancestor-project rows. Project rows are not automatically
trusted: Ink freezes the normalized effective policy and requires digest approval;
child/session policy and the host floor may only narrow it.
Keep argv unsurprising: options before operands, `--` ends options, `-` denotes a
natural stream, secrets never enter argv, and unknown, incompatible, or trailing
@@ -250,7 +252,7 @@ interpreters, or generated-client machinery unless they are the named job.
Use the smallest matrix that can falsify the actual claims:
- help/contract agree with accepted argv and selectors;
- main path and one forbidden near miss with zero unintended effect;
- main path and one forbidden near miss with zero unintended mutation;
- each reader selector has an adjacent no-match before access;
- each mutator has admitted, approval-required, refused, drift, duplicate, and
indeterminate/recovery outcomes as applicable;
@@ -278,7 +280,7 @@ Do not:
- wrap or partially clone a utility whose whole admitted surface already fits;
- combine read and mutation for code reuse;
- expose a generic request/admin/registry platform;
- invent invariant selectors, a universal effect ontology, or executable-name
- invent invariant selectors, a universal mutation ontology, or executable-name
branches in Ink's renderer;
- let tools choose terminal styling or let presentation metadata grant authority;
- treat argv prefixes, globs, or regexes as canonical path authority;
@@ -312,7 +314,7 @@ Report only:
- **Presentation:** an HTTP mutator derives method, canonical target, and bounded
body summary from one staged operation; Ink renders the exact stored projection
without an `httpsend` branch and overlays receipt-bound status/final URL only.
- **Presentation sprawl:** “support AWS changes” does not add an AWS effect enum or
- **Presentation sprawl:** “support AWS changes” does not add an AWS mutation enum or
renderer branch; the permission-sized infrastructure tool projects account,
region, resource, requested change, outcome, and evidence through the elected
generic vocabulary.
+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'
+7
View File
@@ -23,13 +23,20 @@ configure=$repo/skills/configure-ink-agent/SKILL.md
grep 'access.*read.*write' "$configure" >/dev/null
grep 'named agent policy is metadata-only' "$configure" >/dev/null
grep 'no `INK_AGENT_HOME` contract' "$configure" >/dev/null
grep 'applied as a restriction' "$configure" >/dev/null
audit_cli=$repo/skills/audit-ink-cli/SKILL.md
grep 'ink agent catalog' "$audit_cli" >/dev/null
grep 'frozen `tool delegate` subject' "$audit_cli" >/dev/null
grep 'additive alternatives' "$audit_cli" >/dev/null
grep 'automatically trusted' "$audit_cli" >/dev/null
create_tool=$repo/skills/create-ink-tool/SKILL.md
grep 'stage/match/apply' "$create_tool" >/dev/null
grep 'projection' "$create_tool" >/dev/null
grep 'approved global and ancestor-project rows' "$create_tool" >/dev/null
grep 'requires digest approval' "$create_tool" >/dev/null
grep 'Empty project policy adds nothing' "$repo/README.md" >/dev/null
printf 'ok\n'