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
78 changes: 72 additions & 6 deletions scripts/feishu-gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,32 @@
request is sent, and a reply counts only when it **appears later in the stream than that
message** — the same list, read in order.

Spent before it is sent
-----------------------
"Spent on use" is enforced by writing the spend **before** the CLI is invoked, not after it
answers. The record has to be older than the thing it records: a gate that marks the row
`sent` only once the CLI has accepted the message leaves the row `pending` through any death
in between — a crash, a kill, a machine that sleeps past the CLI's own patience — and the
next `send` then passes every check and delivers the same message a second time.

So `send` moves the row to `sending` and rewrites the ledger first, and calls the CLI after
that. Every verb refuses a row that is not `pending`, so the claim is permanent unless the
process that wrote it lives to release it. Whether it releases is a choice, and the choice is
made by whether the failure can be read:

* **A refusal it can read** — the CLI could not be run, or it exited non-zero and said why —
proves nothing left, so the row goes back to `pending` and the approval stays usable. A
fault with no consequence must not cost the host a second round of asking.
* **An answer it cannot read** — a timeout, output that is not JSON, an answer carrying no
message_id — proves nothing either way, so the row stays `sending` and that approval is
gone. Failing closed costs one new request; failing open costs a second message to a third
party, which is the one thing this gate exists to prevent.

A `sending` row is therefore the visible trace of an interrupted send: `list` prints it like
any other row, and `send` refuses it with the remedy rather than as an unexplained state.

What this cannot do, stated rather than left unsaid
--------------------------------------------------
---------------------------------------------------
It stops misuse, not deliberate forgery. The gate runs as the host's own user, and that
identity can itself post as the host. It makes reusing an old approval, redirecting a send,
swapping the body, or bypassing the gate impossible at the argument and ledger level; forging
Expand Down Expand Up @@ -87,6 +111,16 @@ class Refusal(Exception):
"""The gate declined, and nothing was sent."""


class OutcomeUnknown(Refusal):
"""The CLI ran, and the gate could not read what it answered.

A `send` that ends here proves nothing either way: the message may have left before the
CLI went quiet. It is its own class because the caller's next move differs from the one a
plain `Refusal` allows — see "One approval, one message, spent before it is sent" in this
module's docstring.
"""


# ── the workspace's own state ────────────────────────────────────────────


Expand Down Expand Up @@ -160,8 +194,13 @@ def run_cli(cli: str, args: tuple[str, ...] | list[str]) -> dict:
argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=120, check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
except OSError as exc:
# Nothing ran, so nothing left: this is a refusal the caller may undo.
raise Refusal(f"the CLI could not be run ({cli}): {exc}") from exc
except subprocess.TimeoutExpired as exc:
# The timeout is the gate's own patience, not the CLI's verdict: a `send` that
# outlived it may well have gone out.
raise OutcomeUnknown(f"the CLI did not answer within its time: {exc}") from exc
if done.returncode != 0:
detail = (done.stderr or done.stdout or "").strip().splitlines()
raise Refusal(
Expand All @@ -170,7 +209,7 @@ def run_cli(cli: str, args: tuple[str, ...] | list[str]) -> dict:
try:
return json.loads(done.stdout or "{}")
except json.JSONDecodeError as exc:
raise Refusal(f"the CLI answered something that is not JSON: {exc}") from exc
raise OutcomeUnknown(f"the CLI answered something that is not JSON: {exc}") from exc


def host_open_id(cli: str) -> str:
Expand Down Expand Up @@ -199,7 +238,7 @@ def send_message(cli: str, to: str, text: str) -> str:
)
message_id = data.get("message_id") if isinstance(data, dict) else None
if not isinstance(message_id, str) or not message_id:
raise Refusal(f"the send answered no message_id: {data!r}")
raise OutcomeUnknown(f"the send answered no message_id: {data!r}")
return message_id


Expand Down Expand Up @@ -324,7 +363,14 @@ def cmd_send(args: argparse.Namespace) -> None:
f"one approval covers one message"
)
if row.get("status") != "pending":
raise Refusal(f"the token {args.token!r} is {row.get('status')!r}, not pending")
detail = f"the token {args.token!r} is {row.get('status')!r}, not pending"
if row.get("status") == "sending":
detail += (
"; a send that was interrupted before its answer left the approval spent — "
"whether the message went out is unknown, so ask the host again rather than "
"retrying this token"
)
raise Refusal(detail)

created = datetime.fromisoformat(row["created_at"])
if datetime.now(timezone.utc) - created > timedelta(seconds=args.max_age):
Expand Down Expand Up @@ -353,8 +399,28 @@ def cmd_send(args: argparse.Namespace) -> None:
f"{args.token!r}; nothing was sent"
)

message_id = send_message(cli, row["to"], path.read_text(encoding="utf-8"))
# The spend is written before the CLI is invoked, so that the record is older than the
# send it records: a process that dies in between leaves a row every verb refuses, not
# one the next `send` accepts. See "Spent before it is sent" in the module docstring for
# which failures release the claim and why.
row["status"] = "sending"
row["claimed_at"] = datetime.now(timezone.utc).isoformat()
rewrite_ledger(workspace, rows)

try:
message_id = send_message(cli, row["to"], path.read_text(encoding="utf-8"))
except OutcomeUnknown:
# Nothing here proves the message stayed: the claim stands, and this approval is gone.
raise
except Refusal:
# The CLI refused and said so — nothing was sent, so the approval stays spendable.
row["status"] = "pending"
row.pop("claimed_at", None)
rewrite_ledger(workspace, rows)
raise

row["status"] = "sent"
row.pop("claimed_at", None)
row["sent_at"] = datetime.now(timezone.utc).isoformat()
row["sent_message_id"] = message_id
rewrite_ledger(workspace, rows)
Expand Down
107 changes: 105 additions & 2 deletions tests/test_feishu_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
)

FAKE_CLI = '''"""A stand-in for the Feishu CLI: records argv, answers fixed JSON, touches no network."""
import json, os, sys
import json, os, signal, sys
from pathlib import Path

argv = sys.argv[1:]
Expand All @@ -75,15 +75,27 @@ def save(items):
elif argv and argv[0] == "+chat-messages-create":
receive = argv[argv.index("--receive-id") + 1]
text = argv[argv.index("--text") + 1]
if receive and receive == os.environ.get("FAKE_LARK_DECLINE_ON"):
print("the CLI declined this message", file=sys.stderr)
sys.exit(4)
items = stream()
message_id = f"om_{len(items) + 1}"
items.append({
"message_id": message_id,
"receive_id": receive,
"sender_id": os.environ["FAKE_LARK_HOST"],
"body": {"content": json.dumps({"text": text})},
})
save(items)
print(json.dumps({"message_id": message_id, "receive_id": receive}))
if receive == os.environ.get("FAKE_LARK_DIE_ON"):
# The message is out and the gate never learns it — the death this suite is about.
os.kill(os.getppid(), signal.SIGKILL)
os._exit(0)
if receive == os.environ.get("FAKE_LARK_MUMBLE_ON"):
# The message is out and the answer carries no message_id, so the gate cannot tell.
print(json.dumps({"receive_id": receive}))
else:
print(json.dumps({"message_id": message_id, "receive_id": receive}))
else:
print("unexpected argv: %r" % (argv,), file=sys.stderr)
sys.exit(3)
Expand Down Expand Up @@ -139,6 +151,15 @@ def sends(lark) -> list[list[str]]:
return [a for a in argv_lines(lark) if a and a[0] == "+chat-messages-create"]


def third_party_messages(lark) -> list[dict]:
"""What the stand-in CLI actually delivered to a recipient that is not the host."""
path = lark["stream"]
if not path.exists():
return []
items = json.loads(path.read_text(encoding="utf-8"))
return [item for item in items if item.get("receive_id") not in (None, HOST)]


def request_one(lark, to: str = "ou_third_party", text: str = "the verbatim body") -> str:
done = gate(lark, "request", "--to", to, "--text", text)
assert done.returncode == 0, done.stderr
Expand Down Expand Up @@ -249,6 +270,88 @@ def test_the_same_token_cannot_be_spent_twice(fake_lark):
assert len(sends(fake_lark)) == sends_after_first, "a spent approval must not resend"


# ── the spend is recorded before the send it records ────────────────────────


def test_a_gate_that_dies_after_the_send_leaves_the_approval_unspendable(fake_lark):
"""The window a crash opens, and the reason the claim is written before the CLI runs.

The stand-in sends, then ends the gate before it can record the send. The row must
already have been unspendable when the CLI was invoked, so the next `send` refuses — and
because one refusal proves nothing on its own (a gate that refused everything would pass
the second half), the stream must hold exactly one message for the third party.
"""
token = request_one(fake_lark)
host_replies(fake_lark, f"yes {token}")
fake_lark["env"]["FAKE_LARK_DIE_ON"] = "ou_third_party"

killed = gate(fake_lark, "send", "--token", token)

assert killed.returncode != 0, "the stand-in was to end the gate before it wrote"
assert [row["status"] for row in ledger(fake_lark)] == ["sending"], (
"the spend must be on disk before the CLI is invoked, so a death in between leaves a "
"row every verb refuses rather than one the next `send` accepts"
)
assert len(third_party_messages(fake_lark)) == 1, "this arm needs a message that did leave"

fake_lark["env"].pop("FAKE_LARK_DIE_ON")
again = gate(fake_lark, "send", "--token", token)

assert again.returncode == 1
assert "sending" in again.stderr, (
"the refusal must name the state and the remedy, not leave `sending` unexplained"
)
assert len(third_party_messages(fake_lark)) == 1, "one approval covers one message"


def test_a_cli_refusal_does_not_burn_the_approval(fake_lark):
"""The stated trade, one direction: a failure the gate can read sent nothing.

The CLI exited non-zero and said so, so the message did not leave and the approval goes
back to spendable — a fault with no consequence must not cost the host a second asking.
"""
token = request_one(fake_lark)
host_replies(fake_lark, f"yes {token}")
fake_lark["env"]["FAKE_LARK_DECLINE_ON"] = "ou_third_party"

declined = gate(fake_lark, "send", "--token", token)

assert declined.returncode == 1
assert "exited 4" in declined.stderr
assert ledger(fake_lark)[0]["status"] == "pending", (
"a refusal that provably sent nothing must leave the approval spendable"
)
assert third_party_messages(fake_lark) == []

fake_lark["env"].pop("FAKE_LARK_DECLINE_ON")
assert gate(fake_lark, "send", "--token", token).returncode == 0
assert len(third_party_messages(fake_lark)) == 1


def test_an_answer_the_gate_cannot_read_keeps_the_approval_spent(fake_lark):
"""The same trade, the other direction: an unreadable answer proves nothing, so fail closed.

The stand-in delivered the message and answered without a message_id. Retrying would risk
a second delivery, so the row stays `sending` and the honest cost is one new request.
"""
token = request_one(fake_lark)
host_replies(fake_lark, f"yes {token}")
fake_lark["env"]["FAKE_LARK_MUMBLE_ON"] = "ou_third_party"

done = gate(fake_lark, "send", "--token", token)

assert done.returncode == 1
assert "no message_id" in done.stderr
assert len(third_party_messages(fake_lark)) == 1, "this arm needs a message that did leave"
assert ledger(fake_lark)[0]["status"] == "sending"

fake_lark["env"].pop("FAKE_LARK_MUMBLE_ON")
assert gate(fake_lark, "send", "--token", token).returncode == 1
assert len(third_party_messages(fake_lark)) == 1, (
"an answer nobody could read must not license a second delivery"
)


# ── (e) the bad arguments cannot be expressed ───────────────────────────────


Expand Down
Loading