From c67fc4c0f566616a1e22c7b8d50e105c93a4c844 Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Wed, 17 Jun 2026 07:39:39 -0700 Subject: [PATCH 1/3] fix: Bind single-quote-containing config values via readfile() instead of .param set --- scripts/identities.sh | 26 +++++++++++++++++--------- scripts/join.sh | 37 ++++++++++++++++++++++++++++--------- scripts/whoami.sh | 24 ++++++++++++++++-------- tests/test_team.bats | 36 ++++++++++++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/scripts/identities.sh b/scripts/identities.sh index 3901abd..ea2ec19 100755 --- a/scripts/identities.sh +++ b/scripts/identities.sh @@ -15,6 +15,8 @@ set -euo pipefail PROJECT_PATH="${1:?Usage: identities.sh }" AGENT_TYPE="${2:?Missing agent_type}" +PROJECT_SQL=$(printf '%s' "$PROJECT_PATH" | sed "s/'/''/g") +AGENT_TYPE_SQL=$(printf '%s' "$AGENT_TYPE" | sed "s/'/''/g") SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" TEAMS_DIR="$SCRIPT_DIR/../teams" @@ -25,26 +27,32 @@ source "$SCRIPT_DIR/lib/storage.sh" for config_file in "$TEAMS_DIR"/*/config.json; do [ -f "$config_file" ] || continue - CONFIG_ESCAPED=$(sed "s/'/''/g" "$config_file") - TEAM_NAME=$(agmsg_sqlite_mem ".param set :json '$CONFIG_ESCAPED'" \ - "SELECT json_extract(:json, '\$.name');") + cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + TEAM_NAME=$(agmsg_sqlite_mem " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw) + SELECT json_extract(json, '\$.name') FROM cfg; + ") [ -z "$TEAM_NAME" ] && continue [ "$TEAM_NAME" = "null" ] && continue + TEAM_SQL=$(printf '%s' "$TEAM_NAME" | sed "s/'/''/g") - sqlite3 -separator $'\t' :memory: ".param set :json '$CONFIG_ESCAPED'" " - WITH agents AS ( + sqlite3 -separator $'\t' :memory: " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw), + agents AS ( SELECT key AS name, CASE WHEN json_type(json_extract(value, '\$.registrations')) = 'array' THEN json_extract(value, '\$.registrations') ELSE json_array(json_object('type', json_extract(value, '\$.type'), 'project', json_extract(value, '\$.project'))) END AS registrations - FROM json_each(json_extract(:json, '\$.agents')) + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) ) - SELECT DISTINCT '$TEAM_NAME' AS team, name + SELECT DISTINCT '$TEAM_SQL' AS team, name FROM agents, json_each(agents.registrations) AS r - WHERE json_extract(r.value, '\$.project') = '$PROJECT_PATH' - AND json_extract(r.value, '\$.type') = '$AGENT_TYPE' + WHERE json_extract(r.value, '\$.project') = '$PROJECT_SQL' + AND json_extract(r.value, '\$.type') = '$AGENT_TYPE_SQL' ORDER BY team, name; " | tr -d '\r' done diff --git a/scripts/join.sh b/scripts/join.sh index ba8015d..913a8f1 100755 --- a/scripts/join.sh +++ b/scripts/join.sh @@ -55,15 +55,22 @@ EOF fi # --- Add or extend agent registrations --- -CONFIG_ESCAPED=$(sed "s/'/''/g" "$TEAM_CONFIG") -REGISTRATION="{\"type\":\"$AGENT_TYPE\",\"project\":\"$PROJECT_PATH\"}" +CONFIG_SQL=$(printf '%s' "$TEAM_CONFIG" | sed "s/'/''/g") +AGENT_ID_SQL=$(printf '%s' "$AGENT_ID" | sed "s/'/''/g") +AGENT_TYPE_SQL=$(printf '%s' "$AGENT_TYPE" | sed "s/'/''/g") +PROJECT_SQL=$(printf '%s' "$PROJECT_PATH" | sed "s/'/''/g") +REGISTRATION=$(sqlite3 :memory: "SELECT json_object('type', '$AGENT_TYPE_SQL', 'project', '$PROJECT_SQL');") REGISTRATION_ESCAPED=$(printf '%s' "$REGISTRATION" | sed "s/'/''/g") -EXISTING=$(agmsg_sqlite_mem ".param set :json '$CONFIG_ESCAPED'" \ - "SELECT json_extract(:json, '$.agents.$AGENT_ID');") +EXISTING=$(agmsg_sqlite_mem " + WITH cfg AS (SELECT CAST(readfile('$CONFIG_SQL') AS TEXT) AS json) + SELECT value + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) + WHERE key = '$AGENT_ID_SQL'; +") if [ -z "$EXISTING" ] || [ "$EXISTING" = "null" ]; then - AGENT_OBJ="{\"registrations\":[${REGISTRATION}]}" + AGENT_OBJ=$(sqlite3 :memory: "SELECT json_object('registrations', json_array(json('$REGISTRATION_ESCAPED')));") else EXISTING_ESCAPED=$(printf '%s' "$EXISTING" | sed "s/'/''/g") NORMALIZED=$(agmsg_sqlite_mem " @@ -86,8 +93,8 @@ else SELECT EXISTS( SELECT 1 FROM json_each(json_extract('$NORMALIZED_ESCAPED', '\$.registrations')) - WHERE json_extract(value, '\$.type') = '$AGENT_TYPE' - AND json_extract(value, '\$.project') = '$PROJECT_PATH' + WHERE json_extract(value, '\$.type') = '$AGENT_TYPE_SQL' + AND json_extract(value, '\$.project') = '$PROJECT_SQL' ); ") @@ -104,9 +111,21 @@ else fi fi +AGENT_OBJ_ESCAPED=$(printf '%s' "$AGENT_OBJ" | sed "s/'/''/g") UPDATED=$(agmsg_sqlite_mem \ - ".param set :json '$CONFIG_ESCAPED'" \ - "SELECT json_set(:json, '$.agents.$AGENT_ID', json('$(printf '%s' "$AGENT_OBJ" | sed "s/'/''/g")'));") + "WITH cfg AS (SELECT CAST(readfile('$CONFIG_SQL') AS TEXT) AS json) + SELECT json_set( + cfg.json, + '\$.agents', + json_patch( + CASE + WHEN json_type(json_extract(cfg.json, '\$.agents')) = 'object' THEN json_extract(cfg.json, '\$.agents') + ELSE json('{}') + END, + json_object('$AGENT_ID_SQL', json('$AGENT_OBJ_ESCAPED')) + ) + ) + FROM cfg;") echo "$UPDATED" > "$TEAM_CONFIG" echo "Joined team $TEAM as $AGENT_ID" diff --git a/scripts/whoami.sh b/scripts/whoami.sh index c5fcfe8..86276b5 100755 --- a/scripts/whoami.sh +++ b/scripts/whoami.sh @@ -86,6 +86,7 @@ source "$SCRIPT_DIR/lib/resolve-project.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/storage.sh" PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE")" +AGENT_TYPE_SQL=$(printf '%s' "$AGENT_TYPE" | sed "s/'/''/g") if [ ! -d "$TEAMS_DIR" ]; then echo "not_joined=true available_teams=none" @@ -104,28 +105,35 @@ ALL_TEAMS="" for config_file in "$TEAMS_DIR"/*/config.json; do [ -f "$config_file" ] || continue - CONFIG_ESCAPED=$(sed "s/'/''/g" "$config_file") - TEAM_NAME=$(agmsg_sqlite_mem ".param set :json '$CONFIG_ESCAPED'" \ - "SELECT json_extract(:json, '$.name');") - ALL_TEAMS="${ALL_TEAMS:+$ALL_TEAMS,}$TEAM_NAME" + cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + TEAM_NAME=$(agmsg_sqlite_mem " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw) + SELECT json_extract(json, '\$.name') FROM cfg; + ") + if [ -n "$TEAM_NAME" ] && [ "$TEAM_NAME" != "null" ]; then + ALL_TEAMS="${ALL_TEAMS:+$ALL_TEAMS,}$TEAM_NAME" + fi while IFS=' ' read -r agent_name; do [ -n "$agent_name" ] || continue SUGGESTED_MATCHES="${SUGGESTED_MATCHES:+$SUGGESTED_MATCHES }$TEAM_NAME $agent_name" - done < <(sqlite3 -separator ' ' :memory: ".param set :json '$CONFIG_ESCAPED'" " - WITH agents AS ( + done < <(sqlite3 -separator ' ' :memory: " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw), + agents AS ( SELECT key AS name, CASE WHEN json_type(json_extract(value, '\$.registrations')) = 'array' THEN json_extract(value, '\$.registrations') ELSE json_array(json_object('type', json_extract(value, '\$.type'), 'project', json_extract(value, '\$.project'))) END AS registrations - FROM json_each(json_extract(:json, '\$.agents')) + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) ) SELECT DISTINCT name FROM agents, json_each(agents.registrations) AS r - WHERE json_extract(r.value, '\$.type') = '$AGENT_TYPE'; + WHERE json_extract(r.value, '\$.type') = '$AGENT_TYPE_SQL'; " | tr -d '\r') done diff --git a/tests/test_team.bats b/tests/test_team.bats index 3465114..452be52 100644 --- a/tests/test_team.bats +++ b/tests/test_team.bats @@ -84,6 +84,40 @@ teardown() { [[ "$output" =~ "teams=myteam" ]] } +@test "whoami: resolves project paths containing single quotes" { + local project="$TEST_SKILL_DIR/pro'j" + mkdir -p "$project/subdir" + bash "$SCRIPTS/join.sh" myteam alice claude-code "$project" + run bash "$SCRIPTS/whoami.sh" "$project/subdir" claude-code + [ "$status" -eq 0 ] + [[ "$output" =~ "agent=alice" ]] + [[ "$output" =~ "teams=myteam" ]] + [[ "$output" =~ "project=$project" ]] + [[ ! "$output" =~ "not_joined=true" ]] + [[ ! "$output" =~ ".parameter" ]] +} + +@test "whoami: resolves team and agent names containing single quotes" { + local team="O'Brien" + local agent="al'ice" + bash "$SCRIPTS/join.sh" "$team" "$agent" claude-code /tmp/proj + run bash "$SCRIPTS/whoami.sh" /tmp/proj claude-code + [ "$status" -eq 0 ] + [[ "$output" =~ "agent=$agent" ]] + [[ "$output" =~ "teams=$team" ]] + [[ ! "$output" =~ ".parameter" ]] +} + +@test "whoami: ignores malformed team configs without sqlite parameter output" { + mkdir -p "$TEST_SKILL_DIR/teams/bad" + printf '{' > "$TEST_SKILL_DIR/teams/bad/config.json" + run bash "$SCRIPTS/whoami.sh" /tmp/proj claude-code + [ "$status" -eq 0 ] + [[ "$output" =~ "not_joined=true" ]] + [[ ! "$output" =~ ".parameter" ]] + [[ ! "$output" =~ "malformed JSON" ]] +} + @test "whoami: returns not_joined when no match" { run bash "$SCRIPTS/whoami.sh" /tmp/unknown claude-code [ "$status" -eq 0 ] @@ -277,8 +311,6 @@ teardown() { run bash "$SCRIPTS/join.sh" myteam alice opencode /tmp/proj [ "$status" -eq 0 ] } - - # --- #140: team-name path traversal --- @test "join: rejects a team name with path traversal (../)" { From e6439b39e273d50e3e8be77beaeb0ecbffdc55cb Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 13:26:02 -0700 Subject: [PATCH 2/3] fix(resolve-project): readfile() the registry scan too, completing the #112 single-quote fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #144 fixed the .param set single-quote bug in identities/join/whoami but scoped around the shared agmsg_registered_projects helper (overriding it locally instead). Fix the shared helper itself the same way — readfile() + json_valid guard, path/type interpolated with '' escaping — and drop the local overrides so all registry reads go through one corrected path. --- scripts/lib/resolve-project.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/lib/resolve-project.sh b/scripts/lib/resolve-project.sh index f22b4e7..1a22936 100644 --- a/scripts/lib/resolve-project.sh +++ b/scripts/lib/resolve-project.sh @@ -152,22 +152,29 @@ agmsg_marker_gc_stale() { # List distinct registered project paths for , one per line. agmsg_registered_projects() { - local type="$1" teams_dir="$SKILL_DIR/teams" config_file cfg_sql + local type="$1" teams_dir="$SKILL_DIR/teams" config_file cfg_sql type_sql [ -d "$teams_dir" ] || return 0 + # Read config.json inside SQL via readfile() rather than binding it through a + # `.param set` dot-command — the sqlite3 shell tokenizer doesn't honour SQL '' + # escaping, so a config value with a single quote breaks the bind (#112). The + # path and type are interpolated as SQL string literals with '' doubling. + type_sql=$(printf '%s' "$type" | sed "s/'/''/g") for config_file in "$teams_dir"/*/config.json; do [ -f "$config_file" ] || continue - cfg_sql=$(sed "s/'/''/g" "$config_file") - sqlite3 :memory: ".param set :json '$cfg_sql'" " - WITH agents AS ( + cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + sqlite3 :memory: " + WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), + cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw), + agents AS ( SELECT CASE WHEN json_type(json_extract(value, '\$.registrations')) = 'array' THEN json_extract(value, '\$.registrations') ELSE json_array(json_object('type', json_extract(value, '\$.type'), 'project', json_extract(value, '\$.project'))) END AS registrations - FROM json_each(json_extract(:json, '\$.agents')) + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) ) SELECT DISTINCT json_extract(r.value, '\$.project') FROM agents, json_each(agents.registrations) AS r - WHERE json_extract(r.value, '\$.type') = '$type'; + WHERE json_extract(r.value, '\$.type') = '$type_sql'; " | tr -d '\r' done } From d25cd37d8fbb5618c4b460712b2007e7815e2bec Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 16:50:17 -0700 Subject: [PATCH 3/3] fix(registry): cygpath readfile() paths so config reads work on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #112 readfile()-based binding passed Git Bash paths (/d/a/agmsg/...) straight to sqlite3.exe's readfile(), a native binary that can't open them — readfile() returned NULL, identity resolution found nothing, and whoami reported not_joined on Windows (caught by the powershell launcher smoke on the windows-latest required leg). Add agmsg_sql_readfile_path() to storage.sh (cygpath -w on Windows, mirroring delivery.sh's sql_readfile_path) and route the four registry readfile() sites through it. resolve-project.sh sources storage.sh so its scan — reached by actas-claim.sh and friends that don't source it directly — has the helper too. No-op off Windows (cygpath absent). --- scripts/identities.sh | 2 +- scripts/join.sh | 2 +- scripts/lib/resolve-project.sh | 9 ++++++++- scripts/lib/storage.sh | 14 ++++++++++++++ scripts/whoami.sh | 2 +- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/identities.sh b/scripts/identities.sh index ea2ec19..093d9a7 100755 --- a/scripts/identities.sh +++ b/scripts/identities.sh @@ -27,7 +27,7 @@ source "$SCRIPT_DIR/lib/storage.sh" for config_file in "$TEAMS_DIR"/*/config.json; do [ -f "$config_file" ] || continue - cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + cfg_sql=$(agmsg_sql_readfile_path "$config_file") TEAM_NAME=$(agmsg_sqlite_mem " WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw) diff --git a/scripts/join.sh b/scripts/join.sh index 913a8f1..5e5878b 100755 --- a/scripts/join.sh +++ b/scripts/join.sh @@ -55,7 +55,7 @@ EOF fi # --- Add or extend agent registrations --- -CONFIG_SQL=$(printf '%s' "$TEAM_CONFIG" | sed "s/'/''/g") +CONFIG_SQL=$(agmsg_sql_readfile_path "$TEAM_CONFIG") AGENT_ID_SQL=$(printf '%s' "$AGENT_ID" | sed "s/'/''/g") AGENT_TYPE_SQL=$(printf '%s' "$AGENT_TYPE" | sed "s/'/''/g") PROJECT_SQL=$(printf '%s' "$PROJECT_PATH" | sed "s/'/''/g") diff --git a/scripts/lib/resolve-project.sh b/scripts/lib/resolve-project.sh index 1a22936..25b6f67 100644 --- a/scripts/lib/resolve-project.sh +++ b/scripts/lib/resolve-project.sh @@ -29,6 +29,13 @@ : "${SKILL_DIR:?resolve-project.sh requires SKILL_DIR}" +# agmsg_registered_projects() below reads team configs via readfile() and needs +# agmsg_sql_readfile_path(). Not every caller that sources resolve-project.sh +# also sources storage.sh (e.g. actas-claim.sh), so pull it in here. Re-sourcing +# where the caller already has it just redefines the helpers — harmless. +# shellcheck disable=SC1091 +. "$SKILL_DIR/scripts/lib/storage.sh" + _agmsg_run_dir() { printf '%s/run' "$SKILL_DIR"; } # Canonicalize a directory path by resolving symlinks to its physical location. @@ -161,7 +168,7 @@ agmsg_registered_projects() { type_sql=$(printf '%s' "$type" | sed "s/'/''/g") for config_file in "$teams_dir"/*/config.json; do [ -f "$config_file" ] || continue - cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + cfg_sql=$(agmsg_sql_readfile_path "$config_file") sqlite3 :memory: " WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw), diff --git a/scripts/lib/storage.sh b/scripts/lib/storage.sh index 1ad25cd..c669b2f 100644 --- a/scripts/lib/storage.sh +++ b/scripts/lib/storage.sh @@ -92,3 +92,17 @@ agmsg_sqlite() { agmsg_sqlite_mem() { sqlite3 :memory: "$@" | tr -d '\r' } + +# Turn a filesystem path into a form sqlite3's readfile() can open, then escape +# it as a SQL string literal. On Windows, sqlite3.exe is a native binary that +# can't open a Git Bash path like /d/a/agmsg/x.json — readfile() returns NULL +# and the surrounding json parse silently yields no rows. cygpath -w converts to +# the native D:\a\agmsg\x.json form first. No-op off Windows (cygpath absent). +# Mirrors delivery.sh's sql_readfile_path for the registry readfile() sites. +agmsg_sql_readfile_path() { + local path="$1" + if command -v cygpath >/dev/null 2>&1; then + path=$(cygpath -w "$path" 2>/dev/null || printf '%s' "$path") + fi + printf '%s' "$path" | sed "s/'/''/g" +} diff --git a/scripts/whoami.sh b/scripts/whoami.sh index 86276b5..ea475e4 100755 --- a/scripts/whoami.sh +++ b/scripts/whoami.sh @@ -105,7 +105,7 @@ ALL_TEAMS="" for config_file in "$TEAMS_DIR"/*/config.json; do [ -f "$config_file" ] || continue - cfg_sql=$(printf '%s' "$config_file" | sed "s/'/''/g") + cfg_sql=$(agmsg_sql_readfile_path "$config_file") TEAM_NAME=$(agmsg_sqlite_mem " WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)), cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw)