diff --git a/sigil/sessions.py b/sigil/sessions.py index 9282dfb..15bd820 100644 --- a/sigil/sessions.py +++ b/sigil/sessions.py @@ -92,25 +92,50 @@ def issue_agent_session( } -def issue_view_session( - store: Store, - agent: str, - scopes: Optional[List[str]] = None, - ttl: int = 3600, - granted_by: Optional[str] = None, -) -> dict: - """Mint a human_via_agent session. +class GrantedCode: + """Proof that a one-time view code was redeemed. - Plumbing primitive, not an entry point: it trusts whoever calls it. Code - reached from outside the process must authenticate first. issue_code() - enforces that by requiring an agent session, and redeem_code() may present a - one-time code as its credential. + Constructed only by viewcodes.redeem_code(), and only from a code that + actually matched. Minting a human session now requires one of these, so the + session minting path cannot be reached by passing an agent name. - `granted_by` is the agent that approved the code. It is recorded so the - audit trail can answer "who let this human in" and not merely "which - agent's things did they see". + This is a guardrail against wiring mistakes, not a security boundary: any + code running in this process can construct it. The boundary that matters is + that no network-reachable route reaches this function without either an + agent session (issue_code) or a valid code (redeem_code). """ - granted = list(scopes if scopes is not None else HUMAN_VIEW_SCOPES) + + __slots__ = ("agent", "scopes", "ttl", "_sigil_internal") + + def __init__(self, agent: str, scopes: List[str], ttl: int, _internal=None): + if _internal is not _ISSUER: + raise errors.SigilError( + "GrantedCode cannot be constructed directly; it comes from" + " viewcodes.redeem_code()" + ) + self.agent = agent + self.scopes = list(scopes) + self.ttl = ttl + self._sigil_internal = _internal + + def __repr__(self) -> str: + return f"" + + +_ISSUER = object() + + +def issue_view_session(store: Store, grant: "GrantedCode") -> dict: + """Mint a human_via_agent session from a redeemed code. + + Takes a GrantedCode rather than an agent name, so the only way to reach a + human session is to have redeemed a code. `granted_by` is the agent that + approved it, recorded so the audit trail can answer "who let this human in" + and not merely "which agent's things did they see". + """ + agent = grant.agent + granted = list(grant.scopes) + ttl = grant.ttl extra = [s for s in granted if s not in HUMAN_VIEW_SCOPES] if extra: # Defence in depth: even if a caller tries to widen a human session, @@ -125,7 +150,7 @@ def issue_view_session( agent, granted, expires, - granted_by=granted_by or agent, + granted_by=agent, ) store.record( "session.issued", @@ -135,7 +160,7 @@ def issue_view_session( "kind": HUMAN_VIA_AGENT, "scopes": granted, "expires_at": expires, - "granted_by": granted_by or agent, + "granted_by": agent, }, ) return { @@ -143,7 +168,7 @@ def issue_view_session( "kind": HUMAN_VIA_AGENT, "subject": f"{agent}'s human", "on_behalf_of": agent, - "granted_by": granted_by or agent, + "granted_by": agent, "scopes": granted, "expires_at": expires, "ttl": ttl, @@ -198,4 +223,6 @@ def assert_may_act_as_agent(session: dict) -> str: def revoke(store: Store, token: str) -> None: store.revoke_session(hash_token(token)) - store.record("session.revoked", actor="system", detail={"token_prefix": token[:8]}) \ No newline at end of file + # A digest, not a prefix of the token: the log outlives the session. + store.record("session.revoked", actor="system", + detail={"token_ref": "tok:" + hash_token(token)[:12]}) \ No newline at end of file