Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .agents/skills/verify-claudecode-nvim/SKILL.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .agents/skills/verify-claudecode-nvim/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This directory is the maintained source for verifying the user-facing behavior o
## Baseline preconditions

- Launch a fresh run with `.agents/skills/verify-claudecode-nvim/scripts/vcn.sh launch`. It opens `hello.txt` with the default config plus `terminal.provider = "none"`.
- Start every bash call with `export VCN_STATE='<VCN_STATE value launch printed>' OUT='<OUT value launch printed>'` (quoted as SKILL.md says); `WORK=$VCN_STATE/work; V=.agents/skills/verify-claudecode-nvim/scripts/vcn.sh`. Do not `source` a `run.env` in these calls (see SKILL.md).
- Start every bash call with `export VCN_STATE='<VCN_STATE value launch printed>'` (quoted as SKILL.md says); `V=.agents/skills/verify-claudecode-nvim/scripts/vcn.sh; getz() { IFS= read -r -d '' "$1" < <($V get -z "$1"); }; getz OUT; getz WORK`, which keeps a newline at the end of a path, as a command substitution would not. Do not `source` a `run.env` in these calls (see SKILL.md).
- `$WORK/hello.txt` is `alpha`, `beta`, `gamma` and `$WORK/other.txt` is `one`, `two`, `three`, `four`.
- `$V doctor` reports nine `ok` lines, including `a client finished the WebSocket handshake`.
- Never drive an nvim, agent-tty session, or lock file that this run did not start.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
# `PASS` or `FAIL: <count> of <total> checks failed` last. Evidence lands in $OUT as diff-close-save-*.
set -euo pipefail
V="$(dirname "${BASH_SOURCE[0]}")/vcn.sh"
# shellcheck source=/dev/null
source "$VCN_STATE/run.env"
# Read OUT and WORK through vcn.sh get, which checks $VCN_STATE first. -z keeps a newline at the end of a path.
get() { IFS= read -r -d "" "$1" < <("$V" get -z "$1"); }
get OUT
get WORK
target=${1:-existing}
[[ $target == existing || $target == new ]] || {
echo "usage: journey-diff-close-save.sh [existing|new]" >&2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
# Add "layout":"unified" to diff_opts for the unified layout. Evidence lands in $OUT as new-tab-diff-mode-*.
set -euo pipefail
V="$(dirname "${BASH_SOURCE[0]}")/vcn.sh"
# shellcheck source=/dev/null
source "$VCN_STATE/run.env"
# Read OUT and WORK through vcn.sh get, which checks $VCN_STATE first. -z keeps a newline at the end of a path.
get() { IFS= read -r -d "" "$1" < <("$V" get -z "$1"); }
get OUT
get WORK

fail() {
echo "FAIL: $*" >&2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
# Evidence lands in $OUT as opendiff-accept-*.
set -euo pipefail
V="$(dirname "${BASH_SOURCE[0]}")/vcn.sh"
# shellcheck source=/dev/null
source "$VCN_STATE/run.env"
# Read OUT and WORK through vcn.sh get, which checks $VCN_STATE first. -z keeps a newline at the end of a path.
get() { IFS= read -r -d "" "$1" < <("$V" get -z "$1"); }
get OUT
get WORK
FILE="$WORK/hello.txt"
TAB="✻ [Claude Code] hello.txt (vcn) ⧉"
PROPOSED=$'alpha\nBETA\ngamma\n'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ set -euo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
V="$SCRIPTS/vcn.sh"
OBSERVE="$SCRIPTS/observe-windows.lua"
# shellcheck source=/dev/null
source "$VCN_STATE/run.env"
# Read OUT and WORK through vcn.sh get, which checks $VCN_STATE first. -z keeps a newline at the end of a path.
get() { IFS= read -r -d "" "$1" < <("$V" get -z "$1"); }
get OUT
get WORK
TAB="✻ [Claude Code] hello.txt (float) ⧉"

fail() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ set -euo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
V="$SCRIPTS/vcn.sh"
OBSERVE="$SCRIPTS/observe-windows.lua"
# shellcheck source=/dev/null
source "$VCN_STATE/run.env"
# Read OUT and WORK through vcn.sh get, which checks $VCN_STATE first. -z keeps a newline at the end of a path.
get() { IFS= read -r -d "" "$1" < <("$V" get -z "$1"); }
get OUT
get WORK
TAB="✻ [Claude Code] hello.txt (float-reload) ⧉"

fail() {
Expand Down

Large diffs are not rendered by default.

104 changes: 88 additions & 16 deletions .agents/skills/verify-claudecode-nvim/scripts/proof-launch-refusals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
# leads to a safe dir: the resolved path is read with its newline
# tmp-load-refused mktemp answers with a path that is not its own resolved path (a // in it): load refuses the new state dir,
# and launch leaves no state dir and no evidence dir
# node-relative node is found through a relative PATH entry (no mise): its dir would go on PATH in every agent-tty call
# att-root-nosticky /tmp/agent-tty is this user's 0700 dir, and /tmp is world-writable without the sticky bit
# tmp-root TMPDIR is /: launch gives mktemp /vcn.XXXXXX, not //vcn.XXXXXX (a mktemp shim stops launch and prints it)
# tmp-space a dir above TMPDIR that others can write has a name that ends in a space
# out-space the same above VERIFY_OUT
Expand Down Expand Up @@ -50,7 +52,7 @@
# Prints PASS <case> per case, or FAIL: <reason> and exits 1. The init case's evidence dir is
# deleted when every case passes, and kept with its path printed when one fails.
set -euo pipefail
V="$(dirname "${BASH_SOURCE[0]}")/vcn.sh"
V="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vcn.sh"
# Resolved, because launch prints the state dir under the resolved path of TMPDIR.
TMP=$(realpath -- "$(mktemp -d)")
init_out=""
Expand Down Expand Up @@ -143,6 +145,19 @@ refused tmp-dot "TMPDIR=$TMP/dd/./y has a . or .. part" TMPDIR="$TMP/dd/./y" VER
mkdir "$TMP/mktemp-bin"
printf '#!/bin/sh\necho "mktemp shim: template=$2" >&2\nexit 1\n' >"$TMP/mktemp-bin/mktemp"
chmod +x "$TMP/mktemp-bin/mktemp"
# launch resolves node itself. Without mise (a shim that fails stands in, whatever /usr/bin holds), `command -v node` prints a
# relative path for a relative PATH entry, and the dir of that path would go in front of PATH in every agent-tty call. Launch
# refuses before it makes anything.
mkdir -m 700 "$TMP/relnode" "$TMP/relnode/nb" "$TMP/relnode/t" "$TMP/relnode/nomise"
printf '#!/bin/sh\nexit 0\n' >"$TMP/relnode/nb/node"
printf '#!/bin/sh\nexit 1\n' >"$TMP/relnode/nomise/mise"
chmod +x "$TMP/relnode/nb/node" "$TMP/relnode/nomise/mise"
nvim_dir=$(dirname -- "$(mise which nvim 2>/dev/null || command -v nvim)")
(
cd "$TMP/relnode"
refused node-relative "node is at ./nb/node, and its dir is not usable: it is not an absolute path" \
PATH="./nb:$TMP/relnode/nomise:$nvim_dir:/usr/bin:/bin" VCN_AGENT_TTY=/usr/bin/true TMPDIR="$TMP/relnode/t" VERIFY_OUT="$TMP/relnode/o"
)
refused tmp-root "mktemp shim: template=/vcn.XXXXXX" PATH="$TMP/mktemp-bin:$PATH" TMPDIR=/ VERIFY_OUT="$TMP/tmp-out"
# mktemp gets the resolved path. The path as given must pass the same walk, which is main's rule: the symlink itself, its
# owner and the dirs above it.
Expand All @@ -151,23 +166,20 @@ mkdir -m 700 "$TMP/lnk-safe"
ln -s "$TMP/lnk-safe" "$TMP/lnk-loose/t"
refused tmp-link-loose "refusing TMPDIR: $TMP/lnk-loose, above the state dir, is writable by others and not sticky" \
TMPDIR="$TMP/lnk-loose/t" VERIFY_OUT="$TMP/tmp-out"
# bwrap makes a user namespace that this user owns (no sudo, no second account). Root's inodes show as another user there:
# /var/run is a root-owned symlink to /run, and `--dir /run` makes the target this user's, so only the owner of the symlink
# can refuse it. The tools that launch resolves before the TMPDIR check are bound read-only under their own paths.
tmp_link_foreign_case() {
local name=tmp-link-foreign tool exe root repo out rc=0 paths="" binds=()
# bwrap makes a user namespace that this user owns (no sudo, no second account). Root's inodes show as another user there.
# sandbox_base <case> sets sb to the bwrap arguments that the sandboxed launches below share, or prints SKIP and fails. The tools
# that launch resolves before its checks are bound read-only under their own paths, the repo is at /w, and /tmp is a tmpfs of
# its own, so no case touches the host's /tmp.
sandbox_base() {
local name=$1 tool exe root repo paths="" binds=()
command -v bwrap >/dev/null || {
echo "SKIP $name: bwrap is not installed"
return 0
}
[[ -L /var/run && $(stat -c %u /var/run) == 0 && $(realpath /var/run) == /run ]] || {
echo "SKIP $name: /var/run is not a root-owned symlink to /run"
return 0
return 1
}
for tool in nvim node jq; do
exe=$(mise which "$tool" 2>/dev/null || command -v "$tool") || {
echo "SKIP $name: $tool not found"
return 0
return 1
}
exe=$(realpath -- "$exe")
# nvim needs the runtime beside its bin dir.
Expand All @@ -176,14 +188,25 @@ tmp_link_foreign_case() {
paths+=$(dirname -- "$exe"):
done
repo=$(cd "$(dirname "$V")/../../../.." && pwd)
local args=(--unshare-user --uid 0 --gid 0 --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib
sb=(--unshare-user --uid 0 --gid 0 --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib
--symlink usr/lib64 /lib64 --unshare-pid --dev /dev --proc /proc --perms 01777 --tmpfs /tmp "${binds[@]}"
--ro-bind /var /var --dir /run --ro-bind "$repo" /w --setenv PATH "$paths/usr/bin:/bin" --setenv HOME /tmp
--setenv VCN_AGENT_TTY /usr/bin/true --setenv VERIFY_OUT /tmp/out --setenv TMPDIR /var/run)
bwrap "${args[@]}" /usr/bin/true 2>/dev/null || {
--ro-bind "$repo" /w --setenv PATH "$paths/usr/bin:/bin" --setenv HOME /tmp --setenv VERIFY_OUT /tmp/out)
bwrap "${sb[@]}" /usr/bin/true 2>/dev/null || {
echo "SKIP $name: bwrap cannot make a user namespace here"
return 1
}
}

# /var/run is a root-owned symlink to /run, and `--dir /run` makes the target this user's, so only the owner of the symlink
# can refuse it.
tmp_link_foreign_case() {
local name=tmp-link-foreign out rc=0
[[ -L /var/run && $(stat -c %u /var/run) == 0 && $(realpath /var/run) == /run ]] || {
echo "SKIP $name: /var/run is not a root-owned symlink to /run"
return 0
}
sandbox_base "$name" || return 0
local args=("${sb[@]}" --ro-bind /var /var --dir /run --setenv VCN_AGENT_TTY /usr/bin/true --setenv TMPDIR /var/run)
out=$(bwrap "${args[@]}" /usr/bin/bash -c '[[ $(stat -c %u /var/run) != "$EUID" && $(stat -c %u /run) == "$EUID" ]] || echo SETUP-WRONG-OWNERS
/w/.agents/skills/verify-claudecode-nvim/scripts/vcn.sh launch; echo "rc=$?"' 2>&1) || rc=$?
[[ $out != *SETUP-WRONG-OWNERS* ]] || fail "$name: the symlink is not another user's or its target is not this user's, so the case proves nothing: $out"
Expand All @@ -192,6 +215,55 @@ tmp_link_foreign_case() {
echo "PASS $name: TMPDIR=/var/run, a symlink that another user owns, was refused before launch made anything"
}
tmp_link_foreign_case

# agent-tty keeps every socket under the fixed dir /tmp/agent-tty and never checks who owns it. Another user who owns it, or who
# can rename entries in it, could swap the socket dir of a run and answer every command. Launch makes the dir 0700 when it is
# missing and refuses one that is not this user's alone.
# att_root_case <case> <planting shell> <want> <mode> [bwrap args]: launch in a sandbox after the planting shell ran. <want> is
# the text of a refusal, which must leave nothing behind, or empty when launch must get past the check and leave
# /tmp/agent-tty with <mode> and this user as its owner. The agent-tty here is `false`, so a launch that got past the check stops
# at the next step, `agent-tty doctor`, and says so: that text, not only the absence of one refusal, is what shows the launch went
# on, so a guard that refuses a safe dir with any other message fails the case.
att_root_case() {
local name=$1 plant=$2 want=$3 mode=$4 out rc=0
shift 4
sandbox_base "$name" || return 0
local args=("${sb[@]}" "$@" --setenv VCN_AGENT_TTY /usr/bin/false)
out=$(bwrap "${args[@]}" /usr/bin/bash -c "$plant"'
/w/.agents/skills/verify-claudecode-nvim/scripts/vcn.sh launch; echo "rc=$?"
[[ ! -e /tmp/out && ! -e /run/o && -z $(ls -d /tmp/vcn.* /run/t/vcn.* 2>/dev/null) ]] || echo LEFT-BEHIND
stat -c "ROOT %a %u" /tmp/agent-tty 2>&1' 2>&1) || rc=$?
if [[ -n $want ]]; then
[[ $out == *"refusing the agent-tty socket dir: $want"* && $out == *"rc=1"* && $out != *VCN_STATE=* && $out != *LEFT-BEHIND* ]] ||
fail "$name: launch did not refuse /tmp/agent-tty with '$want', exit 1 and nothing left behind: $out"
echo "PASS $name: $want, and launch refused it before it made anything"
else
[[ $out != *"refusing the agent-tty socket dir"* && $out == *'no agent-tty passes `agent-tty doctor`'* && $out == *"rc=1"* && $out == *"ROOT $mode 0"* ]] ||
fail "$name: launch did not go on to agent-tty doctor after /tmp/agent-tty, or left it with a mode other than $mode or another owner: $out"
echo "PASS $name: launch accepted /tmp/agent-tty, went on to agent-tty doctor and left the dir with mode $mode"
fi
}
host_root_dir=""
for cand in /srv /opt /usr /etc; do
[[ $(stat -c '%u %a' -- "$cand" 2>/dev/null) == "0 755" ]] && {
host_root_dir=$cand
break
}
done
if [[ -n $host_root_dir ]]; then
att_root_case att-root-foreign : "/tmp/agent-tty is owned by another user" "" --ro-bind "$host_root_dir" /tmp/agent-tty
else
echo "SKIP att-root-foreign: no host-root-owned 0755 dir to bind"
fi
att_root_case att-root-loose 'mkdir -m 777 /tmp/agent-tty' "/tmp/agent-tty is group- or world-writable" ""
att_root_case att-root-link 'ln -s /tmp /tmp/agent-tty' "/tmp/agent-tty is a symlink" ""
att_root_case att-root-made : "" 700
att_root_case att-root-own-700 'mkdir -m 700 /tmp/agent-tty' "" 700
att_root_case att-root-own-755 'mkdir -m 755 /tmp/agent-tty' "" 755
# The dir is safe, but /tmp is not sticky, so another user could rename it. TMPDIR and VERIFY_OUT sit in a dir of their own, so
# only the check of the dirs above /tmp/agent-tty can refuse.
att_root_case att-root-nosticky 'mkdir -m 700 /tmp/agent-tty' "/tmp, above the agent-tty socket dir, is writable by others and not sticky" "" \
--perms 0777 --tmpfs /tmp --dir /run/t --setenv TMPDIR /run/t --setenv VERIFY_OUT /run/o
# A newline at the end of a name is cut by a command substitution. The resolved TMPDIR is a dir with a newline at the end of its
# name and mode 777, and a twin symlink without the newline leads to a safe dir: a launch that cuts the newline would take the twin.
mkdir -m 700 "$TMP/nl" "$TMP/nl/safe"
Expand Down
Loading
Loading