Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
77 changes: 12 additions & 65 deletions airc
Original file line number Diff line number Diff line change
Expand Up @@ -1897,71 +1897,18 @@ if host:
PYEOF
}

cmd_kick() {
# Host-only: forcibly remove a paired peer. IRC analog: /kick <user>.
# Steps: emit a system event, drop their SSH pubkey from authorized_keys,
# remove the peer file. The kicked peer's tail loop dies on the closed
# pipe AND any future auth attempts fail because their key is gone from
# authorized_keys — they can't silently keep operating after a kick.
# They can re-pair via airc connect (no ban yet) — for that, see future
# `airc ban`.
ensure_init
local target="${1:-}"
[ -z "$target" ] && die "Usage: airc kick <peer> [reason]"
_validate_peer_name "$target"
shift || true
local reason="${*:-no reason given}"

# Joiner role check — kicking only makes sense as host.
local host_target; host_target=$(get_config_val host_target "")
if [ -n "$host_target" ]; then
die "kick: only the room host can kick. You are a joiner of $host_target — talk to the host."
fi

local peer_file="$PEERS_DIR/$target.json"
if [ ! -f "$peer_file" ]; then
die "kick: '$target' not in peers list (try: airc peers)"
fi

# Read the joiner's SSH pubkey from the peer JSON record (the host
# handshake stores it there — `<peer>.pub` holds the SIGNING pubkey,
# not the SSH auth key, so we can't use that file). Without this,
# kick would leave the joiner's SSH key in authorized_keys and the
# peer could keep authenticating despite the "kick" — caught by
# Copilot review on PR #73.
local peer_ssh_pub
peer_ssh_pub=$(PEER_FILE="$peer_file" "$AIRC_PYTHON" -c '
import json, os
try:
p = json.load(open(os.environ["PEER_FILE"]))
print((p.get("ssh_pub") or "").strip())
except Exception:
pass
' 2>/dev/null || echo "")

if [ -n "$peer_ssh_pub" ] && [ -f "$HOME/.ssh/authorized_keys" ]; then
# grep -v returns 1 when every line matches (or the file is empty);
# both are fine outcomes here, so eat the exit code.
grep -vF "$peer_ssh_pub" "$HOME/.ssh/authorized_keys" > "$HOME/.ssh/authorized_keys.tmp" 2>/dev/null || true
[ -f "$HOME/.ssh/authorized_keys.tmp" ] && mv "$HOME/.ssh/authorized_keys.tmp" "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys" 2>/dev/null || true
fi

# Remove peer files (rm -f is set-e-safe). The .pub here is the
# signing key file, separate from authorized_keys.
rm -f "$peer_file" "$PEERS_DIR/$target.pub"

# Emit a system event so the kicked peer (and others) see it in the
# tail stream. Reuse cmd_send's plumbing.
cmd_send "[kick] $target ($reason)" >/dev/null 2>&1 || true

if [ -n "$peer_ssh_pub" ]; then
echo " Kicked $target ($reason). SSH key removed from authorized_keys; peer file gone."
else
echo " Kicked $target ($reason). Peer file gone, but no SSH key recorded for this peer — they were paired before #34's handshake update; their authorized_keys entry survived. Run airc peers to confirm."
fi
echo " They can re-pair via airc connect; for permanent ban, see future 'airc ban'."
}
# cmd_kick extracted to lib/airc_bash/cmd_kick.sh
# (#152 Phase 3 file split). Host-only peer eviction lives in its own
# file rather than the identity bundle — kick is moderation, not
# identity — and pulling it out first makes the surrounding identity
# block contiguous for the next extraction PR.
if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_kick.sh" ]; then
# shellcheck source=lib/airc_bash/cmd_kick.sh
source "$_airc_lib_dir/airc_bash/cmd_kick.sh"
else
echo "ERROR: airc_bash/cmd_kick.sh not found via lib-dir resolver." >&2
exit 1
fi

# ── Identity import/push (issue #34 v2) ─────────────────────────────────
#
Expand Down
82 changes: 82 additions & 0 deletions lib/airc_bash/cmd_kick.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Sourced by airc. cmd_kick — host-only peer eviction.
#
# Function exported to airc's dispatch:
# cmd_kick — forcibly remove a paired peer (IRC /kick analog).
# Emits a system event, drops the peer's SSH pubkey from
# authorized_keys, deletes the peer file. The kicked
# peer's tail loop dies on the closed pipe; future SSH
# auth attempts fail because their key is gone.
#
# External cross-references (call-time): die, ensure_init, get_config_val,
# resolve_name, AIRC_HOME, AIRC_WRITE_DIR, MESSAGES.
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "External cross-references" list appears stale/inaccurate for this extracted module: cmd_kick uses _validate_peer_name, cmd_send, PEERS_DIR, AIRC_PYTHON, and $HOME/.ssh/authorized_keys, but does not reference resolve_name, AIRC_HOME, AIRC_WRITE_DIR, or MESSAGES. Please update the list so future extraction/refactor work doesn’t rely on incorrect dependencies.

Suggested change
# resolve_name, AIRC_HOME, AIRC_WRITE_DIR, MESSAGES.
# _validate_peer_name, cmd_send, PEERS_DIR, AIRC_PYTHON,
# $HOME/.ssh/authorized_keys.

Copilot uses AI. Check for mistakes.
#
# Extracted from airc as part of #152 Phase 3 file split. Standalone
# (not bundled with identity) because kick is host moderation, not
# identity — separating now also lets the identity bundle pull cleanly
# in the next PR.

cmd_kick() {
# Host-only: forcibly remove a paired peer. IRC analog: /kick <user>.
# Steps: emit a system event, drop their SSH pubkey from authorized_keys,
# remove the peer file. The kicked peer's tail loop dies on the closed
# pipe AND any future auth attempts fail because their key is gone from
# authorized_keys — they can't silently keep operating after a kick.
# They can re-pair via airc connect (no ban yet) — for that, see future
# `airc ban`.
ensure_init
local target="${1:-}"
[ -z "$target" ] && die "Usage: airc kick <peer> [reason]"
_validate_peer_name "$target"
shift || true
local reason="${*:-no reason given}"

# Joiner role check — kicking only makes sense as host.
local host_target; host_target=$(get_config_val host_target "")
if [ -n "$host_target" ]; then
die "kick: only the room host can kick. You are a joiner of $host_target — talk to the host."
fi

local peer_file="$PEERS_DIR/$target.json"
if [ ! -f "$peer_file" ]; then
die "kick: '$target' not in peers list (try: airc peers)"
fi

# Read the joiner's SSH pubkey from the peer JSON record (the host
# handshake stores it there — `<peer>.pub` holds the SIGNING pubkey,
# not the SSH auth key, so we can't use that file). Without this,
# kick would leave the joiner's SSH key in authorized_keys and the
# peer could keep authenticating despite the "kick" — caught by
# Copilot review on PR #73.
local peer_ssh_pub
peer_ssh_pub=$(PEER_FILE="$peer_file" "$AIRC_PYTHON" -c '
import json, os
try:
p = json.load(open(os.environ["PEER_FILE"]))
print((p.get("ssh_pub") or "").strip())
except Exception:
pass
' 2>/dev/null || echo "")
Comment on lines +51 to +58
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block uses an inline Python snippet to read ssh_pub out of the peer JSON. Since airc already provides get_config_val_in <path> <key> <default> (backed by airc_core.config) and it’s used elsewhere for peer-file reads, consider using that helper here (and then trimming whitespace) to reduce duplicated JSON-parsing logic.

Suggested change
peer_ssh_pub=$(PEER_FILE="$peer_file" "$AIRC_PYTHON" -c '
import json, os
try:
p = json.load(open(os.environ["PEER_FILE"]))
print((p.get("ssh_pub") or "").strip())
except Exception:
pass
' 2>/dev/null || echo "")
peer_ssh_pub=$(get_config_val_in "$peer_file" "ssh_pub" "")
peer_ssh_pub=$(printf '%s' "$peer_ssh_pub" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')

Copilot uses AI. Check for mistakes.

if [ -n "$peer_ssh_pub" ] && [ -f "$HOME/.ssh/authorized_keys" ]; then
# grep -v returns 1 when every line matches (or the file is empty);
# both are fine outcomes here, so eat the exit code.
grep -vF "$peer_ssh_pub" "$HOME/.ssh/authorized_keys" > "$HOME/.ssh/authorized_keys.tmp" 2>/dev/null || true
[ -f "$HOME/.ssh/authorized_keys.tmp" ] && mv "$HOME/.ssh/authorized_keys.tmp" "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys" 2>/dev/null || true
fi

# Remove peer files (rm -f is set-e-safe). The .pub here is the
# signing key file, separate from authorized_keys.
rm -f "$peer_file" "$PEERS_DIR/$target.pub"

# Emit a system event so the kicked peer (and others) see it in the
# tail stream. Reuse cmd_send's plumbing.
cmd_send "[kick] $target ($reason)" >/dev/null 2>&1 || true

if [ -n "$peer_ssh_pub" ]; then
echo " Kicked $target ($reason). SSH key removed from authorized_keys; peer file gone."
else
echo " Kicked $target ($reason). Peer file gone, but no SSH key recorded for this peer — they were paired before #34's handshake update; their authorized_keys entry survived. Run airc peers to confirm."
fi
echo " They can re-pair via airc connect; for permanent ban, see future 'airc ban'."
}
Loading