216 lines
13 KiB
Markdown
216 lines
13 KiB
Markdown
---
|
|
title: "Bitable Agent Tool Parity: Action Registration and Ownership Patterns"
|
|
date: 2026-07-04
|
|
category: docs/solutions/architecture-patterns/
|
|
module: bitable
|
|
problem_type: architecture_pattern
|
|
component: agent_tool
|
|
severity: medium
|
|
applies_when:
|
|
- Extending an agent tool with new actions that mirror existing REST endpoints (agent parity)
|
|
- Registering actions in a tool that uses both a handlers dict and a JSON schema enum (dual sync contract)
|
|
- Designing ownership checks for DELETE endpoints where resource existence itself is sensitive (404-before-403)
|
|
- Protecting last-of-kind resources from accidental deletion (409 last-item guard)
|
|
- Bridging a Vue 3 frontend and a Python agent tool over the same domain schema (Pydantic v2 + TypeScript Literal)
|
|
tags: [agent-parity, tool-registration, ownership-check, idor, last-view-protection, pydantic-v2, agent-native-contract, bitable]
|
|
---
|
|
|
|
# Bitable Agent Tool Parity: Action Registration and Ownership Patterns
|
|
|
|
## Context
|
|
|
|
The Bitable companion service ships 28 REST endpoints, but the `BitableTool` (the interface agents call during ReAct loops) initially exposed only 6 actions. This gap creates an **agent orphan** condition: agents cannot reach existing backend capabilities without falling back to raw HTTP, which breaks the tool-use contract (structured input/output, schema validation, evolution hooks).
|
|
|
|
Closing this gap (U6 / R15a) introduced three architecture patterns worth recording for the next agent-tool extension or reviewer:
|
|
|
|
1. **Dual-sync action registration** — handlers dict and `input_schema.action.enum` must be updated together
|
|
2. **404-before-403 ownership check** — for DELETE endpoints where resource existence itself is sensitive
|
|
3. **409 last-view protection** — preventing deletion of the last view on a table
|
|
|
|
These patterns are not framework defaults; they are project-specific contracts that surfaced during `ce-code-review` and would otherwise be re-derived from first principles each time.
|
|
|
|
## Guidance
|
|
|
|
### 1. Dual-Sync Action Registration (KTD10)
|
|
|
|
When a tool exposes its action list via **both** a runtime handlers dict and a static JSON schema enum, the two sources of truth must stay in lockstep. A new handler without a matching enum entry is unreachable by the LLM (the schema rejects it); a new enum entry without a matching handler raises at runtime.
|
|
|
|
**The contract.** `input_schema.properties.action.enum` and `handlers` dict keys must contain the exact same action set. The enum is the LLM's menu; the handlers dict is the runtime dispatch. Neither is derivable from the other.
|
|
|
|
```python
|
|
# src/agentkit/tools/bitable_tool.py
|
|
|
|
# 1. Static schema enum — the LLM sees this
|
|
INPUT_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": [
|
|
"create_table", "import_excel", "import_database", "collect_api",
|
|
"upsert_records", "query_records",
|
|
"create_view", "update_view", "update_field", "delete_view", # U6 additions
|
|
],
|
|
},
|
|
# ... other fields
|
|
},
|
|
}
|
|
|
|
# 2. Runtime dispatch — the executor sees this
|
|
self.handlers = {
|
|
"create_table": self._create_table,
|
|
"import_excel": self._import_excel,
|
|
"import_database": self._import_database,
|
|
"collect_api": self._collect_api,
|
|
"upsert_records": self._upsert_records,
|
|
"query_records": self._query_records,
|
|
"create_view": self._create_view,
|
|
"update_view": self._update_view,
|
|
"update_field": self._update_field,
|
|
"delete_view": self._delete_view,
|
|
}
|
|
|
|
# 3. Assert the contract at construction — fail loud, not silent
|
|
assert len(INPUT_SCHEMA["properties"]["action"]["enum"]) == len(self.handlers), \
|
|
"action enum and handlers dict drifted"
|
|
```
|
|
|
|
**Test contract.** Two tests pin the contract so a future addition that updates one source but not the other fails CI:
|
|
|
|
```python
|
|
# tests/unit/bitable/test_bitable_tool.py
|
|
def test_action_enum_has_10_actions():
|
|
actions = BitableTool.INPUT_SCHEMA["properties"]["action"]["enum"]
|
|
assert len(actions) == 10
|
|
assert "delete_view" in actions
|
|
|
|
def test_execute_handlers_dict_has_10_actions():
|
|
tool = BitableTool(...)
|
|
assert len(tool.handlers) == 10
|
|
assert "delete_view" in tool.handlers
|
|
```
|
|
|
|
**Why dual-sync instead of single source.** The schema is a static JSON document consumed by the LLM at planning time; the handlers dict is a runtime dispatch table. They live in different layers (data vs. code) and cannot share a single declaration without a metaprogramming layer. The assertion + test pair is the smallest reliable guard.
|
|
|
|
### 2. 404-Before-403 Ownership Check (KTD9)
|
|
|
|
For DELETE endpoints where the mere existence of a resource is sensitive (e.g., a view name might leak a competitor's table structure), return **404 Not Found** when the caller does not own the resource — not 403 Forbidden. A 403 confirms the resource exists, which is itself an information disclosure.
|
|
|
|
**The pattern.** Ownership check happens after existence lookup; if the caller is not the owner, treat it as if the resource does not exist:
|
|
|
|
```python
|
|
# src/agentkit/bitable/service.py
|
|
async def delete_view(self, view_id: str, user: dict, internal_token: str | None) -> bool:
|
|
view = await self._repo.get_view(view_id)
|
|
if view is None:
|
|
raise HTTPException(404, "view not found")
|
|
|
|
# Ownership: internal token bypasses (service-to-service), users must own
|
|
if not internal_token and view.owner_id != user["sub"]:
|
|
# NOT 403 — return 404 to avoid confirming existence
|
|
raise HTTPException(404, "view not found")
|
|
|
|
# Last-view protection (see pattern 3)
|
|
siblings = await self._repo.list_views(view.table_id)
|
|
if len(siblings) <= 1:
|
|
raise LastViewDeletionError("cannot delete the last view on a table")
|
|
|
|
return await self._repo.delete_view(view_id)
|
|
```
|
|
|
|
**Why 404, not 403.** A 403 response leaks that the resource exists but the caller lacks permission. For endpoints that mutate or delete, this leak enables enumeration attacks (an attacker can map the resource space by sending DELETEs and watching for 403 vs 404). The 404-before-403 pattern collapses "not yours" and "does not exist" into the same response, denying the attacker the existence signal.
|
|
|
|
**Internal token bypass.** Service-to-service callers (e.g., the agent runtime calling BitableTool) authenticate via `X-Internal-Token` verified with `hmac.compare_digest`. Internal token holders bypass the ownership check because they operate with elevated privileges — but they never bypass validation or the last-view guard.
|
|
|
|
### 3. 409 Last-View Protection
|
|
|
|
A table without any views is structurally invalid (no way to render or interact with records). Deleting the last view on a table returns **409 Conflict**, not 200, to prevent the system from entering an unrecoverable state.
|
|
|
|
```python
|
|
# src/agentkit/bitable/service.py
|
|
class LastViewDeletionError(HTTPException):
|
|
def __init__(self, detail: str):
|
|
super().__init__(status_code=409, detail=detail)
|
|
|
|
# In delete_view:
|
|
if len(siblings) <= 1:
|
|
raise LastViewDeletionError("cannot delete the last view on a table")
|
|
```
|
|
|
|
**Why 409, not 400.** 409 Conflict signals "the request conflicts with the current state of the resource" — the resource exists and the caller has permission, but the operation would leave the system in an invalid state. 400 would imply the request itself was malformed, which it was not.
|
|
|
|
**Frontend contract.** The frontend store's `deleteView` action must handle 409 distinctly from 404 — a 409 warrants a warning toast ("cannot delete the last view"), while a 404 warrants a refresh ("this view no longer exists"):
|
|
|
|
```typescript
|
|
// src/agentkit/server/frontend/src/stores/bitable.ts
|
|
async deleteView(viewId: string) {
|
|
try {
|
|
await api.deleteView(viewId);
|
|
// ... remove from local state
|
|
} catch (e) {
|
|
if (e.response?.status === 409) {
|
|
message.warning("无法删除最后一个视图");
|
|
} else if (e.response?.status === 404) {
|
|
// already gone — refresh
|
|
await this.fetchViews();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Why This Matters
|
|
|
|
These three patterns close the **agent parity gap** without weakening the security posture that the companion service established (see [bitable-companion-service-security-reliability-patterns.md](./bitable-companion-service-security-reliability-patterns.md) for the underlying IDOR/SSRF/SQL-injection baseline).
|
|
|
|
- **Dual-sync registration** ensures agents can actually reach new actions. Without the assertion + test pair, a schema/handler drift silently makes new actions unreachable or runtime-crashes — both failure modes are hard to detect in CI because the LLM never calls the missing action during unit tests.
|
|
- **404-before-403** prevents resource enumeration via DELETE endpoints. The agent parity work exposed new mutating endpoints (delete_view, update_field); applying this pattern from the start avoids a security regression that would otherwise surface in a later audit.
|
|
- **409 last-view protection** preserves structural invariants. A table with zero views is a data integrity issue that the schema layer cannot enforce (views are a presentation layer over records); the service layer must.
|
|
|
|
## When to Apply
|
|
|
|
- **Dual-sync registration**: any tool that exposes `action` (or equivalent) via both a JSON schema enum and a runtime dispatch table. If the tool has only one source of truth (e.g., handlers inferred from schema at runtime), this pattern does not apply.
|
|
- **404-before-403**: any DELETE or mutation endpoint where resource existence is sensitive. For endpoints where existence is already public (e.g., a public profile view), 403 is acceptable.
|
|
- **409 last-item guard**: any "last of kind" resource where deletion would leave the parent in an invalid state. Examples: last view on a table, last column on a board, last admin in an org.
|
|
|
|
## Examples
|
|
|
|
### Adding a new action (the dual-sync checklist)
|
|
|
|
When extending `BitableTool` with a new action, follow this exact sequence:
|
|
|
|
1. Add the action name to `INPUT_SCHEMA["properties"]["action"]["enum"]`
|
|
2. Add the handler method `_new_action` to the tool class
|
|
3. Register the handler in `self.handlers` dict
|
|
4. Update the two contract tests (`test_action_enum_has_N_actions` and `test_execute_handlers_dict_has_N_actions`) with the new count
|
|
5. Add a focused test for the new handler's happy path and one error path
|
|
|
|
Skipping step 1 makes the action unreachable (LLM never calls it). Skipping step 2 or 3 raises `KeyError` at runtime. Skipping step 4 lets a future regression slip through CI.
|
|
|
|
### Ownership check with internal token bypass
|
|
|
|
```python
|
|
async def delete_view(self, view_id: str, user: dict, internal_token: str | None) -> bool:
|
|
view = await self._repo.get_view(view_id)
|
|
if view is None:
|
|
raise HTTPException(404, "view not found")
|
|
if not internal_token and view.owner_id != user["sub"]:
|
|
raise HTTPException(404, "view not found") # 404, not 403
|
|
# ... last-view guard + delete
|
|
```
|
|
|
|
The `internal_token` check uses `hmac.compare_digest` at the route layer (not shown) before reaching the service. The service trusts the route's token verification and only checks whether to apply the ownership bypass.
|
|
|
|
## Known Limitations
|
|
|
|
- **TOCTOU race in delete_view**: the `list_views` count and the `delete_view` call are not atomic. Two concurrent DELETEs for the last two views can both pass the `len(siblings) <= 1` check before either commits, leaving the table with zero views. Fix: atomic conditional DELETE (`DELETE ... WHERE table_id=:t AND (SELECT COUNT(*) FROM views WHERE table_id=:t) > 1 RETURNING id`) or `SELECT ... FOR UPDATE` transaction. Acceptable for v1 single-user target; flagged as P2 follow-up.
|
|
- **`_update_field` silent type drop**: the tool forwards `type` as `field_type` but `UpdateFieldRequest` (Pydantic, extra=ignore) silently drops it. Agents receive 200/204 for a type change that did not persist. Fix: extend `UpdateFieldRequest` to accept `field_type`, or return 400/422 when `type` is set but unsupported. Flagged as P2 follow-up.
|
|
|
|
## Cross-References
|
|
|
|
- [bitable-companion-service-security-reliability-patterns.md](./bitable-companion-service-security-reliability-patterns.md) — underlying IDOR, SSRF, SQL-injection, and internal-token patterns for the Bitable companion service
|
|
- `docs/plans/2026-07-03-001-feat-bitable-p0-ux-and-agent-parity-plan.md` — U6 implementation plan (R15a)
|
|
- `src/agentkit/tools/bitable_tool.py` — dual-sync contract (lines 57-68 enum, lines 175-186 handlers)
|
|
- `src/agentkit/bitable/service.py` — `delete_view` with 404-before-403 and 409 last-view guard
|
|
- `tests/unit/bitable/test_bitable_tool.py` — dual-sync contract tests
|
|
- `tests/unit/bitable/test_routes.py` — DELETE /views 204/404/409/IDOR/token coverage
|