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
26 changes: 22 additions & 4 deletions src/ita/_orientation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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}")
Expand All @@ -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}")

Expand Down
1 change: 1 addition & 0 deletions tests/test_contract_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/test_issue_362_group_e.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading