diff --git a/src/ita/_orientation.py b/src/ita/_orientation.py index 207b70a..2a9f03e 100644 --- a/src/ita/_orientation.py +++ b/src/ita/_orientation.py @@ -6,7 +6,7 @@ import iterm2 from ._core import cli, run_iterm, strip, __version__, \ add_protected, remove_protected, get_protected, resolve_session, \ - parse_filter, match_filter, snapshot + parse_filter, match_filter, snapshot, check_protected from ._envelope import ita_command from ._state import derive_state @@ -131,8 +131,10 @@ def version(): @click.option('--list', 'list_only', is_flag=True, help='List all protected sessions.') @click.option('--json', 'use_json', is_flag=True, help='Emit CONTRACT §4 envelope on stdout.') +@click.option('--force-protected', 'force_protected', is_flag=True, + help='Override protected-session guard (§14.4, #294).') @ita_command(op='protect') -def protect(session_id, list_only, use_json): +def protect(session_id, list_only, use_json, force_protected): """Mark a session as protected — write commands (run, send, key, inject, close) will refuse to target it without --force. Use this to guard the Claude Code terminal or any session you don't want accidentally modified. @@ -157,6 +159,10 @@ async def _resolve(connection): return session.session_id sid = run_iterm(_resolve) was_protected = sid in get_protected() + # §14.4: even `protect` itself gates on already-protected targets for + # auditability — re-protecting a protected session requires explicit + # --force-protected ack so the caller can't silently overwrite state. + check_protected(sid, force_protected=force_protected) add_protected(sid) if not use_json: click.echo(f"Protected: {sid}") @@ -171,12 +177,24 @@ async def _resolve(connection): @cli.command() @click.option('-s', '--session', 'session_id', default=None, help='Session to unprotect.') -def unprotect(session_id): - """Remove protection from a session (reverse of `ita protect`).""" +@click.option('--force-protected', 'force_protected', is_flag=True, + help='Override protected-session guard (§14.4, #294). ' + 'Required to actually remove protection from a protected session — ' + 'the caller must acknowledge they know the target is protected.') +def unprotect(session_id, force_protected): + """Remove protection from a session (reverse of `ita protect`). + + §14.4 (security): even though this command's purpose is to drop the + protected flag, it still gates on `check_protected` without + `--force-protected`. Rationale: the invariant is "no silent bypass of + protection state," and silently disarming a protected session is the + exact bypass we're preventing. Callers must pass --force-protected to + acknowledge the target is protected before removing it.""" async def _resolve(connection): session = await resolve_session(connection, session_id) return session.session_id sid = run_iterm(_resolve) + check_protected(sid, force_protected=force_protected) remove_protected(sid) click.echo(f"Unprotected: {sid}") diff --git a/tests/test_contract_matrix.py b/tests/test_contract_matrix.py index 2cbd058..5286a2d 100644 --- a/tests/test_contract_matrix.py +++ b/tests/test_contract_matrix.py @@ -297,6 +297,7 @@ def test_rule3_rc_matches_envelope_on_ghost_target(cmd: str): _RULE4_WIRED: frozenset[str] = frozenset({ "run", "send", "inject", "key", "close", "restart", "clear", + "protect", "unprotect", }) # Everything in MUTATORS & TARGET_TAKERS that's NOT in _RULE4_WIRED is diff --git a/tests/test_issue_362_group_e.py b/tests/test_issue_362_group_e.py new file mode 100644 index 0000000..29d773a --- /dev/null +++ b/tests/test_issue_362_group_e.py @@ -0,0 +1,65 @@ +"""Rule-4 Group E (#362): protect / unprotect must gate on check_protected. + +Both commands live in `src/ita/_orientation.py` and, until this wave, were +the top-priority xfail from `docs/rule4-xfails-plan.md`: +`unprotect` could silently disarm a protected session (§14.4 bypass). + +These tests drive the in-process `seeded_protection` harness to verify +rc=3 / error.code=protected when the target is in the protected set and +`--force-protected` is not passed — and that `--force-protected` bypasses +the gate as designed.""" +from __future__ import annotations + +import pytest + +from _contract_helpers import ( + GHOST_SID, + invoke, + invoke_json, + seeded_protection, +) + + +@pytest.mark.parametrize("cmd", ["protect", "unprotect"]) +def test_gates_on_protected_without_force(cmd: str): + """§14.4: protect/unprotect exit rc=3 when target is protected and + --force-protected is absent.""" + with seeded_protection(GHOST_SID): + r = invoke(cmd, "-s", GHOST_SID, timeout=10) + assert r.returncode == 3, ( + f"{cmd}: expected rc=3 (protected), got rc={r.returncode}\n" + f"stdout={r.stdout[:400]!r} stderr={r.stderr[:400]!r}" + ) + + +def test_protect_envelope_shape_on_protection_failure(): + """§14.4 + §4: `protect --json` surfaces ok=false, error.code='protected'. + + `unprotect` is not yet @ita_command-wrapped (no --json support), so its + envelope-shape contract is N/A this wave — rc=3 coverage from the test + above is sufficient for §14.4. Migrating unprotect to @ita_command is + tracked separately (not in-scope for #362 Group E).""" + with seeded_protection(GHOST_SID): + r, env = invoke_json("protect", "-s", GHOST_SID, timeout=10) + assert r.returncode == 3 + assert env is not None, f"protect --json produced no envelope: {r.stdout!r}" + assert env.get("ok") is False + err = env.get("error") or {} + assert err.get("code") == "protected", ( + f"protect: expected error.code='protected', got {err!r}" + ) + + +def test_force_protected_bypasses_gate(): + """`--force-protected` on protect short-circuits check_protected and + succeeds (re-protects an already-protected session). Regression guard + for the bypass semantics (#294).""" + with seeded_protection(GHOST_SID): + r = invoke("protect", "-s", GHOST_SID, "--force-protected", timeout=10) + # rc=0 expected. But note: add_protected writes to ~/.ita_protected on + # the real filesystem; we only care here that check_protected did NOT + # raise. rc=3 would indicate the gate fired despite --force-protected. + assert r.returncode != 3, ( + f"--force-protected did not bypass the gate; rc={r.returncode}\n" + f"stdout={r.stdout[:400]!r} stderr={r.stderr[:400]!r}" + )