tiphys.security

Tiphys security components.

Provides multi-layer policy engine, command analysis, and ask protocol.

 1"""
 2Tiphys security components.
 3
 4Provides multi-layer policy engine, command analysis, and ask protocol.
 5"""
 6
 7from tiphys.security.analyze import (
 8    CommandAnalysis,
 9    RiskLevel,
10    analyze_command,
11    is_command_safe,
12)
13from tiphys.security.ask import AskProtocol, PendingRequest, ask_permission
14from tiphys.security.policy import (
15    PolicyEngine,
16    PolicyLayer,
17    ToolContext,
18    ToolPermission,
19    create_default_policy,
20)
21
22__all__ = [
23    # Policy Engine
24    "PolicyEngine",
25    "PolicyLayer",
26    "ToolContext",
27    "ToolPermission",
28    "create_default_policy",
29    # Command Analysis
30    "CommandAnalysis",
31    "RiskLevel",
32    "analyze_command",
33    "is_command_safe",
34    # Ask Protocol
35    "AskProtocol",
36    "PendingRequest",
37    "ask_permission",
38]
class PolicyEngine:
 44class PolicyEngine:
 45    """
 46    Multi-layer security policy engine.
 47
 48    A tool must pass ALL layers to be allowed (intersection logic).
 49    Layers are checked in order: global -> provider -> agent -> profile -> group -> sandbox.
 50    """
 51
 52    LAYER_ORDER = ["global", "provider", "agent", "profile", "group", "sandbox"]
 53
 54    def __init__(self, config: TiphysConfig | None = None) -> None:
 55        """Initialize policy engine with configuration."""
 56        self._config = config
 57        self._layers: dict[str, PolicyLayer] = {}
 58
 59    def add_layer(self, layer: PolicyLayer) -> None:
 60        """Add or update a policy layer."""
 61        self._layers[layer.name] = layer
 62
 63    def remove_layer(self, name: str) -> None:
 64        """Remove a policy layer."""
 65        self._layers.pop(name, None)
 66
 67    def get_layer(self, name: str) -> PolicyLayer | None:
 68        """Get a policy layer by name."""
 69        return self._layers.get(name)
 70
 71    def check(
 72        self,
 73        tool_name: str,
 74        context: ToolContext | None = None,
 75    ) -> ToolPermission:
 76        """
 77        Check if a tool is allowed in the given context.
 78
 79        Args:
 80            tool_name: Name of the tool to check.
 81            context: Optional context for policy evaluation.
 82
 83        Returns:
 84            ToolPermission indicating whether the tool is allowed, denied, or requires approval.
 85        """
 86        # Get all applicable layers in order
 87        layers = self._get_applicable_layers(context)
 88        return self._resolve(tool_name, layers)
 89
 90    def _resolve(
 91        self,
 92        tool_name: str,
 93        layers: list[PolicyLayer],
 94    ) -> ToolPermission:
 95        """Resolve effective permission across all layers."""
 96        needs_ask = False
 97
 98        for layer in layers:
 99            result = self._check_layer(tool_name, layer)
100
101            # Denied by any layer = denied overall
102            if result == ToolPermission.DENIED:
103                return ToolPermission.DENIED
104
105            # Track if any layer requires asking
106            if result == ToolPermission.ASK:
107                needs_ask = True
108
109        return ToolPermission.ASK if needs_ask else ToolPermission.ALLOWED
110
111    def _check_layer(
112        self,
113        tool_name: str,
114        layer: PolicyLayer,
115    ) -> ToolPermission:
116        """Check a single policy layer."""
117        # Deny takes priority
118        if self._matches(tool_name, layer.deny):
119            return ToolPermission.DENIED
120
121        # If allow list exists, must match
122        if layer.allow is not None and not self._matches(tool_name, layer.allow):
123            return ToolPermission.DENIED
124
125        # Check if tool requires approval
126        if self._matches(tool_name, layer.ask):
127            return ToolPermission.ASK
128
129        return ToolPermission.ALLOWED
130
131    def _matches(self, tool: str, patterns: list[str]) -> bool:
132        """Check if tool name matches any pattern."""
133        return any(fnmatch(tool, pattern) for pattern in patterns)
134
135    def _get_applicable_layers(self, context: ToolContext | None) -> list[PolicyLayer]:
136        """Get all applicable policy layers for the given context."""
137        layers: list[PolicyLayer] = []
138
139        for layer_name in self.LAYER_ORDER:
140            if layer_name in self._layers:
141                layer = self._layers[layer_name]
142
143                # Filter layers based on context
144                if context is not None:
145                    # Skip sandbox layer if not sandboxed
146                    if layer_name == "sandbox" and not context.is_sandboxed:
147                        continue
148
149                    # Skip guest profile layer for trusted users
150                    if layer_name == "profile" and context.profile != "guest":
151                        continue
152
153                layers.append(layer)
154
155        return layers

Multi-layer security policy engine.

A tool must pass ALL layers to be allowed (intersection logic). Layers are checked in order: global -> provider -> agent -> profile -> group -> sandbox.

PolicyEngine(config: tiphys.TiphysConfig | None = None)
54    def __init__(self, config: TiphysConfig | None = None) -> None:
55        """Initialize policy engine with configuration."""
56        self._config = config
57        self._layers: dict[str, PolicyLayer] = {}

Initialize policy engine with configuration.

LAYER_ORDER = ['global', 'provider', 'agent', 'profile', 'group', 'sandbox']
def add_layer(self, layer: PolicyLayer) -> None:
59    def add_layer(self, layer: PolicyLayer) -> None:
60        """Add or update a policy layer."""
61        self._layers[layer.name] = layer

Add or update a policy layer.

def remove_layer(self, name: str) -> None:
63    def remove_layer(self, name: str) -> None:
64        """Remove a policy layer."""
65        self._layers.pop(name, None)

Remove a policy layer.

def get_layer(self, name: str) -> PolicyLayer | None:
67    def get_layer(self, name: str) -> PolicyLayer | None:
68        """Get a policy layer by name."""
69        return self._layers.get(name)

Get a policy layer by name.

def check( self, tool_name: str, context: ToolContext | None = None) -> ToolPermission:
71    def check(
72        self,
73        tool_name: str,
74        context: ToolContext | None = None,
75    ) -> ToolPermission:
76        """
77        Check if a tool is allowed in the given context.
78
79        Args:
80            tool_name: Name of the tool to check.
81            context: Optional context for policy evaluation.
82
83        Returns:
84            ToolPermission indicating whether the tool is allowed, denied, or requires approval.
85        """
86        # Get all applicable layers in order
87        layers = self._get_applicable_layers(context)
88        return self._resolve(tool_name, layers)

Check if a tool is allowed in the given context.

Args: tool_name: Name of the tool to check. context: Optional context for policy evaluation.

Returns: ToolPermission indicating whether the tool is allowed, denied, or requires approval.

class PolicyLayer(pydantic.main.BaseModel):
24class PolicyLayer(BaseModel):
25    """A single policy layer with allow/deny/ask rules."""
26
27    name: str
28    allow: list[str] | None = None  # None = allow all not denied
29    deny: list[str] = Field(default_factory=list)
30    ask: list[str] = Field(default_factory=list)

A single policy layer with allow/deny/ask rules.

name: str = PydanticUndefined
allow: list[str] | None = None
deny: list[str] = PydanticUndefined
ask: list[str] = PydanticUndefined
class ToolContext(pydantic.main.BaseModel):
33class ToolContext(BaseModel):
34    """Context for policy evaluation."""
35
36    agent_id: str
37    channel: str = "webchat"
38    peer_id: str | None = None
39    is_sandboxed: bool = False
40    profile: str = "trusted"  # "trusted" or "guest"
41    group_id: str | None = None

Context for policy evaluation.

agent_id: str = PydanticUndefined
channel: str = 'webchat'
peer_id: str | None = None
is_sandboxed: bool = False
profile: str = 'trusted'
group_id: str | None = None
class ToolPermission(builtins.str, enum.Enum):
16class ToolPermission(str, Enum):
17    """Result of a policy check."""
18
19    ALLOWED = "allowed"
20    DENIED = "denied"
21    ASK = "ask"  # Requires user approval

Result of a policy check.

ALLOWED = <ToolPermission.ALLOWED: 'allowed'>
DENIED = <ToolPermission.DENIED: 'denied'>
ASK = <ToolPermission.ASK: 'ask'>
def create_default_policy(config: tiphys.TiphysConfig) -> PolicyEngine:
158def create_default_policy(config: TiphysConfig) -> PolicyEngine:
159    """
160    Create a policy engine with default layers based on configuration.
161
162    Args:
163        config: Tiphys configuration.
164
165    Returns:
166        Configured PolicyEngine instance.
167    """
168    engine = PolicyEngine(config)
169
170    # Global layer based on exec_mode
171    if config.exec_mode == "deny":
172        # Deny all command execution
173        global_layer = PolicyLayer(
174            name="global",
175            deny=["run_command", "shell", "exec"],
176        )
177    elif config.exec_mode == "allowlist":
178        # Only allow specific commands (handled at command level, not tool level)
179        global_layer = PolicyLayer(
180            name="global",
181            ask=["run_command"],  # Require approval for commands
182        )
183    else:  # full
184        # Allow everything
185        global_layer = PolicyLayer(
186            name="global",
187            allow=["*"],
188        )
189
190    engine.add_layer(global_layer)
191
192    # Guest profile layer - more restrictive
193    guest_layer = PolicyLayer(
194        name="profile",
195        deny=["run_command", "write_file", "delete_*"],
196        allow=["read_file", "list_directory", "search"],
197    )
198    engine.add_layer(guest_layer)
199
200    # Sandbox layer - when running in container
201    sandbox_layer = PolicyLayer(
202        name="sandbox",
203        deny=["network_*", "sudo_*"],
204    )
205    engine.add_layer(sandbox_layer)
206
207    return engine

Create a policy engine with default layers based on configuration.

Args: config: Tiphys configuration.

Returns: Configured PolicyEngine instance.

@dataclass
class CommandAnalysis:
145@dataclass
146class CommandAnalysis:
147    """Analysis of a shell command."""
148
149    raw_command: str
150    binaries: list[str] = field(default_factory=list)
151    has_pipes: bool = False
152    has_chaining: bool = False  # &&, ||, ;
153    has_redirects: bool = False
154    has_subshell: bool = False  # $() or ``
155    has_wildcards: bool = False
156    risk_level: RiskLevel = RiskLevel.LOW
157    reasons: list[str] = field(default_factory=list)
158    is_allowed: bool = True

Analysis of a shell command.

CommandAnalysis( raw_command: str, binaries: list[str] = <factory>, has_pipes: bool = False, has_chaining: bool = False, has_redirects: bool = False, has_subshell: bool = False, has_wildcards: bool = False, risk_level: RiskLevel = <RiskLevel.LOW: 'low'>, reasons: list[str] = <factory>, is_allowed: bool = True)
raw_command: str
binaries: list[str]
has_pipes: bool = False
has_chaining: bool = False
has_redirects: bool = False
has_subshell: bool = False
has_wildcards: bool = False
risk_level: RiskLevel = <RiskLevel.LOW: 'low'>
reasons: list[str]
is_allowed: bool = True
class RiskLevel(builtins.str, enum.Enum):
14class RiskLevel(str, Enum):
15    """Risk level of a command."""
16
17    LOW = "low"
18    MEDIUM = "medium"
19    HIGH = "high"
20    CRITICAL = "critical"

Risk level of a command.

LOW = <RiskLevel.LOW: 'low'>
MEDIUM = <RiskLevel.MEDIUM: 'medium'>
HIGH = <RiskLevel.HIGH: 'high'>
CRITICAL = <RiskLevel.CRITICAL: 'critical'>
def analyze_command( cmd: str, allowed_commands: list[str] | None = None) -> CommandAnalysis:
161def analyze_command(cmd: str, allowed_commands: list[str] | None = None) -> CommandAnalysis:
162    """
163    Analyze a shell command for security risks.
164
165    Args:
166        cmd: The shell command to analyze.
167        allowed_commands: Optional list of allowed command binaries.
168
169    Returns:
170        CommandAnalysis with risk assessment.
171    """
172    analysis = CommandAnalysis(raw_command=cmd)
173    reasons: list[str] = []
174
175    # Check for pipes
176    analysis.has_pipes = "|" in cmd
177
178    # Check for command chaining
179    analysis.has_chaining = any(op in cmd for op in ["&&", "||", ";"])
180
181    # Check for redirects
182    analysis.has_redirects = any(op in cmd for op in [">", ">>", "<", "2>", "&>"])
183
184    # Check for subshells
185    analysis.has_subshell = "$(" in cmd or "`" in cmd
186
187    # Check for wildcards
188    analysis.has_wildcards = any(c in cmd for c in ["*", "?", "["])
189
190    # Parse command to extract binaries
191    binaries = _extract_binaries(cmd)
192    analysis.binaries = binaries
193
194    # Determine risk level
195    risk_level = RiskLevel.LOW
196
197    # Subshells are high risk (arbitrary code execution)
198    if analysis.has_subshell:
199        risk_level = _max_risk(risk_level, RiskLevel.HIGH)
200        reasons.append("Contains subshell execution")
201
202    # Pipes and chaining increase risk
203    if analysis.has_pipes or analysis.has_chaining:
204        risk_level = _max_risk(risk_level, RiskLevel.MEDIUM)
205        reasons.append("Contains pipes or command chaining")
206
207    # Redirects can overwrite files
208    if analysis.has_redirects:
209        risk_level = _max_risk(risk_level, RiskLevel.MEDIUM)
210        reasons.append("Contains output redirection")
211
212    # Check each binary
213    for binary in binaries:
214        binary_name = Path(binary).name
215
216        if binary_name in DANGEROUS_BINARIES:
217            risk_level = RiskLevel.CRITICAL
218            reasons.append(f"Uses dangerous binary: {binary_name}")
219        elif binary_name not in SAFE_BINARIES:
220            risk_level = _max_risk(risk_level, RiskLevel.MEDIUM)
221            reasons.append(f"Uses unknown binary: {binary_name}")
222
223    # Check against allowlist if provided
224    if allowed_commands is not None:
225        for binary in binaries:
226            binary_name = Path(binary).name
227            if binary_name not in allowed_commands:
228                analysis.is_allowed = False
229                reasons.append(f"Binary not in allowlist: {binary_name}")
230
231    analysis.risk_level = risk_level
232    analysis.reasons = reasons
233
234    return analysis

Analyze a shell command for security risks.

Args: cmd: The shell command to analyze. allowed_commands: Optional list of allowed command binaries.

Returns: CommandAnalysis with risk assessment.

def is_command_safe(cmd: str, allowed_commands: list[str] | None = None) -> bool:
278def is_command_safe(cmd: str, allowed_commands: list[str] | None = None) -> bool:
279    """
280    Quick check if a command is considered safe.
281
282    Args:
283        cmd: The shell command to check.
284        allowed_commands: Optional list of allowed command binaries.
285
286    Returns:
287        True if the command is considered safe.
288    """
289    analysis = analyze_command(cmd, allowed_commands)
290    return analysis.risk_level in (RiskLevel.LOW, RiskLevel.MEDIUM) and analysis.is_allowed

Quick check if a command is considered safe.

Args: cmd: The shell command to check. allowed_commands: Optional list of allowed command binaries.

Returns: True if the command is considered safe.

class AskProtocol:
 31class AskProtocol:
 32    """
 33    Interactive permission request system.
 34
 35    Allows the agent to request user approval for risky operations.
 36    Requests are sent via the event bus and responses are awaited with timeout.
 37    """
 38
 39    DEFAULT_TIMEOUT = 300.0  # 5 minutes
 40
 41    def __init__(self, event_bus: EventBus) -> None:
 42        """
 43        Initialize the ask protocol.
 44
 45        Args:
 46            event_bus: Event bus for sending requests and receiving responses.
 47        """
 48        self.event_bus = event_bus
 49        self._pending: dict[str, asyncio.Future[bool]] = {}
 50
 51        # Subscribe to permission responses
 52        self.event_bus.subscribe("permission_response", self._handle_response)
 53
 54    async def request_permission(
 55        self,
 56        action: str,
 57        details: str,
 58        agent_id: str,
 59        tool_name: str | None = None,
 60        arguments: dict[str, object] | None = None,
 61        timeout: float | None = None,
 62    ) -> bool:
 63        """
 64        Request user permission for an action.
 65
 66        Args:
 67            action: Type of action (e.g., "run_command", "write_file").
 68            details: Human-readable description of what will happen.
 69            agent_id: ID of the agent requesting permission.
 70            tool_name: Optional name of the tool being used.
 71            arguments: Optional tool arguments for context.
 72            timeout: Timeout in seconds. Defaults to 5 minutes.
 73
 74        Returns:
 75            True if approved, False if denied or timeout.
 76        """
 77        if timeout is None:
 78            timeout = self.DEFAULT_TIMEOUT
 79
 80        request = PendingRequest(
 81            action=action,
 82            details=details,
 83            agent_id=agent_id,
 84            tool_name=tool_name,
 85            arguments=arguments,
 86        )
 87
 88        # Create a future to wait for response
 89        future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
 90        self._pending[request.request_id] = future
 91
 92        try:
 93            # Emit permission request event
 94            event = PermissionRequestEvent(
 95                request_id=request.request_id,
 96                action=action,
 97                details=details,
 98                agent_id=agent_id,
 99            )
100            await self.event_bus.broadcast(event)
101
102            await logger.ainfo(
103                "Permission requested",
104                request_id=request.request_id,
105                action=action,
106                agent_id=agent_id,
107            )
108
109            # Wait for response with timeout
110            approved = await asyncio.wait_for(future, timeout=timeout)
111
112            await logger.ainfo(
113                "Permission response received",
114                request_id=request.request_id,
115                approved=approved,
116            )
117
118            return approved
119
120        except TimeoutError:
121            await logger.awarning(
122                "Permission request timed out",
123                request_id=request.request_id,
124                timeout=timeout,
125            )
126            return False
127
128        except asyncio.CancelledError:
129            await logger.awarning(
130                "Permission request cancelled",
131                request_id=request.request_id,
132            )
133            return False
134
135        finally:
136            # Clean up pending request
137            self._pending.pop(request.request_id, None)
138
139    def handle_response(self, request_id: str, approved: bool) -> bool:
140        """
141        Handle a permission response from the user.
142
143        This is called when the user approves or denies a request.
144
145        Args:
146            request_id: ID of the request being responded to.
147            approved: Whether the request was approved.
148
149        Returns:
150            True if the request was found and handled, False otherwise.
151        """
152        if request_id not in self._pending:
153            return False
154
155        future = self._pending[request_id]
156        if not future.done():
157            future.set_result(approved)
158        return True
159
160    async def _handle_response(self, event: "BaseEvent") -> None:
161        """Handle permission response events from the bus."""
162        # Type narrowing - we only subscribe to permission_response events
163        if isinstance(event, PermissionResponseEvent):
164            self.handle_response(event.request_id, event.approved)
165
166    def cancel_request(self, request_id: str) -> bool:
167        """
168        Cancel a pending permission request.
169
170        Args:
171            request_id: ID of the request to cancel.
172
173        Returns:
174            True if the request was found and cancelled, False otherwise.
175        """
176        if request_id not in self._pending:
177            return False
178
179        future = self._pending.pop(request_id)
180        if not future.done():
181            future.cancel()
182        return True
183
184    def cancel_all(self) -> int:
185        """
186        Cancel all pending permission requests.
187
188        Returns:
189            Number of requests cancelled.
190        """
191        count = 0
192        for request_id in list(self._pending.keys()):
193            if self.cancel_request(request_id):
194                count += 1
195        return count
196
197    @property
198    def pending_count(self) -> int:
199        """Number of pending permission requests."""
200        return len(self._pending)

Interactive permission request system.

Allows the agent to request user approval for risky operations. Requests are sent via the event bus and responses are awaited with timeout.

AskProtocol(event_bus: tiphys.server.EventBus)
41    def __init__(self, event_bus: EventBus) -> None:
42        """
43        Initialize the ask protocol.
44
45        Args:
46            event_bus: Event bus for sending requests and receiving responses.
47        """
48        self.event_bus = event_bus
49        self._pending: dict[str, asyncio.Future[bool]] = {}
50
51        # Subscribe to permission responses
52        self.event_bus.subscribe("permission_response", self._handle_response)

Initialize the ask protocol.

Args: event_bus: Event bus for sending requests and receiving responses.

DEFAULT_TIMEOUT = 300.0
event_bus
async def request_permission( self, action: str, details: str, agent_id: str, tool_name: str | None = None, arguments: dict[str, object] | None = None, timeout: float | None = None) -> bool:
 54    async def request_permission(
 55        self,
 56        action: str,
 57        details: str,
 58        agent_id: str,
 59        tool_name: str | None = None,
 60        arguments: dict[str, object] | None = None,
 61        timeout: float | None = None,
 62    ) -> bool:
 63        """
 64        Request user permission for an action.
 65
 66        Args:
 67            action: Type of action (e.g., "run_command", "write_file").
 68            details: Human-readable description of what will happen.
 69            agent_id: ID of the agent requesting permission.
 70            tool_name: Optional name of the tool being used.
 71            arguments: Optional tool arguments for context.
 72            timeout: Timeout in seconds. Defaults to 5 minutes.
 73
 74        Returns:
 75            True if approved, False if denied or timeout.
 76        """
 77        if timeout is None:
 78            timeout = self.DEFAULT_TIMEOUT
 79
 80        request = PendingRequest(
 81            action=action,
 82            details=details,
 83            agent_id=agent_id,
 84            tool_name=tool_name,
 85            arguments=arguments,
 86        )
 87
 88        # Create a future to wait for response
 89        future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
 90        self._pending[request.request_id] = future
 91
 92        try:
 93            # Emit permission request event
 94            event = PermissionRequestEvent(
 95                request_id=request.request_id,
 96                action=action,
 97                details=details,
 98                agent_id=agent_id,
 99            )
100            await self.event_bus.broadcast(event)
101
102            await logger.ainfo(
103                "Permission requested",
104                request_id=request.request_id,
105                action=action,
106                agent_id=agent_id,
107            )
108
109            # Wait for response with timeout
110            approved = await asyncio.wait_for(future, timeout=timeout)
111
112            await logger.ainfo(
113                "Permission response received",
114                request_id=request.request_id,
115                approved=approved,
116            )
117
118            return approved
119
120        except TimeoutError:
121            await logger.awarning(
122                "Permission request timed out",
123                request_id=request.request_id,
124                timeout=timeout,
125            )
126            return False
127
128        except asyncio.CancelledError:
129            await logger.awarning(
130                "Permission request cancelled",
131                request_id=request.request_id,
132            )
133            return False
134
135        finally:
136            # Clean up pending request
137            self._pending.pop(request.request_id, None)

Request user permission for an action.

Args: action: Type of action (e.g., "run_command", "write_file"). details: Human-readable description of what will happen. agent_id: ID of the agent requesting permission. tool_name: Optional name of the tool being used. arguments: Optional tool arguments for context. timeout: Timeout in seconds. Defaults to 5 minutes.

Returns: True if approved, False if denied or timeout.

def handle_response(self, request_id: str, approved: bool) -> bool:
139    def handle_response(self, request_id: str, approved: bool) -> bool:
140        """
141        Handle a permission response from the user.
142
143        This is called when the user approves or denies a request.
144
145        Args:
146            request_id: ID of the request being responded to.
147            approved: Whether the request was approved.
148
149        Returns:
150            True if the request was found and handled, False otherwise.
151        """
152        if request_id not in self._pending:
153            return False
154
155        future = self._pending[request_id]
156        if not future.done():
157            future.set_result(approved)
158        return True

Handle a permission response from the user.

This is called when the user approves or denies a request.

Args: request_id: ID of the request being responded to. approved: Whether the request was approved.

Returns: True if the request was found and handled, False otherwise.

def cancel_request(self, request_id: str) -> bool:
166    def cancel_request(self, request_id: str) -> bool:
167        """
168        Cancel a pending permission request.
169
170        Args:
171            request_id: ID of the request to cancel.
172
173        Returns:
174            True if the request was found and cancelled, False otherwise.
175        """
176        if request_id not in self._pending:
177            return False
178
179        future = self._pending.pop(request_id)
180        if not future.done():
181            future.cancel()
182        return True

Cancel a pending permission request.

Args: request_id: ID of the request to cancel.

Returns: True if the request was found and cancelled, False otherwise.

def cancel_all(self) -> int:
184    def cancel_all(self) -> int:
185        """
186        Cancel all pending permission requests.
187
188        Returns:
189            Number of requests cancelled.
190        """
191        count = 0
192        for request_id in list(self._pending.keys()):
193            if self.cancel_request(request_id):
194                count += 1
195        return count

Cancel all pending permission requests.

Returns: Number of requests cancelled.

pending_count: int
197    @property
198    def pending_count(self) -> int:
199        """Number of pending permission requests."""
200        return len(self._pending)

Number of pending permission requests.

class PendingRequest(pydantic.main.BaseModel):
20class PendingRequest(BaseModel):
21    """A pending permission request."""
22
23    request_id: str = Field(default_factory=lambda: str(uuid4()))
24    action: str
25    details: str
26    agent_id: str
27    tool_name: str | None = None
28    arguments: dict[str, object] | None = None

A pending permission request.

request_id: str = PydanticUndefined
action: str = PydanticUndefined
details: str = PydanticUndefined
agent_id: str = PydanticUndefined
tool_name: str | None = None
arguments: dict[str, object] | None = None
async def ask_permission( event_bus: tiphys.server.EventBus, action: str, details: str, agent_id: str, timeout: float = 300.0) -> bool:
204async def ask_permission(
205    event_bus: EventBus,
206    action: str,
207    details: str,
208    agent_id: str,
209    timeout: float = 300.0,
210) -> bool:
211    """
212    Request user permission for an action (convenience function).
213
214    Args:
215        event_bus: Event bus for communication.
216        action: Type of action.
217        details: Human-readable description.
218        agent_id: ID of the requesting agent.
219        timeout: Timeout in seconds.
220
221    Returns:
222        True if approved, False if denied or timeout.
223    """
224    protocol = AskProtocol(event_bus)
225    return await protocol.request_permission(
226        action=action,
227        details=details,
228        agent_id=agent_id,
229        timeout=timeout,
230    )

Request user permission for an action (convenience function).

Args: event_bus: Event bus for communication. action: Type of action. details: Human-readable description. agent_id: ID of the requesting agent. timeout: Timeout in seconds.

Returns: True if approved, False if denied or timeout.