feat: refactor sgclaw around zeroclaw compat runtime

This commit is contained in:
zyl
2026-03-26 16:23:31 +08:00
parent bca5b75801
commit ff0771a83f
1059 changed files with 409460 additions and 23 deletions

View File

@@ -0,0 +1,314 @@
# LinkedIn Tool — Design Spec
**Date:** 2026-03-13
**Status:** Approved
**Risk tier:** Medium (new tool, external API, credential handling)
## Summary
Native LinkedIn integration tool for ZeroClaw. Enables the agent to create posts,
list its own posts, comment, react, delete posts, view post engagement, and retrieve
profile info — all through LinkedIn's official REST API with OAuth2 authentication.
## Motivation
Enable ZeroClaw to autonomously publish LinkedIn content on a schedule (via cron),
drawing from the user's memory, project history, and Medium feed. Removes dependency
on third-party platforms like Composio for social media posting.
## Required OAuth2 scopes
Users must grant these scopes when creating their LinkedIn Developer App:
| Scope | Required for |
|---|---|
| `w_member_social` | `create_post`, `comment`, `react`, `delete_post` |
| `r_liteprofile` | `get_profile` |
| `r_member_social` | `list_posts`, `get_engagement` |
The "Share on LinkedIn" and "Sign In with LinkedIn using OpenID Connect" products
must be requested in the LinkedIn Developer App dashboard (both auto-approve).
## Architecture
### File structure
| File | Role |
|---|---|
| `src/tools/linkedin.rs` | `Tool` trait impl, action dispatch, parameter validation |
| `src/tools/linkedin_client.rs` | OAuth2 token management, LinkedIn REST API wrappers |
| `src/tools/mod.rs` | Module declaration, pub use, registration in `all_tools_with_runtime` |
| `src/config/schema.rs` | `[linkedin]` config section (`LinkedInConfig`) |
| `src/config/mod.rs` | Add `LinkedInConfig` to pub use exports |
### No new dependencies
All required crates are already in `Cargo.toml`: `reqwest` (HTTP), `serde`/`serde_json`
(serialization), `chrono` (timestamps), `tokio` (async fs for .env reading).
## Config
### `config.toml`
```toml
[linkedin]
enabled = false
```
### `.env` credentials
```bash
LINKEDIN_CLIENT_ID=your_client_id
LINKEDIN_CLIENT_SECRET=your_client_secret
LINKEDIN_ACCESS_TOKEN=your_access_token
LINKEDIN_REFRESH_TOKEN=your_refresh_token
LINKEDIN_PERSON_ID=your_person_urn_id
```
Token format: `LINKEDIN_PERSON_ID` is the bare ID (e.g., `dXNlcjpA...`), not the
full URN. The client prefixes `urn:li:person:` internally.
## Tool design
### Single tool, action-dispatched
Tool name: `linkedin`
The LLM calls it with an `action` field and action-specific parameters:
```json
{ "action": "create_post", "text": "...", "visibility": "PUBLIC" }
```
### Actions
| Action | Params | API | Write? |
|---|---|---|---|
| `create_post` | `text`, `visibility?` (PUBLIC/CONNECTIONS, default PUBLIC), `article_url?`, `article_title?` | `POST /rest/posts` | Yes |
| `list_posts` | `count?` (default 10, max 50) | `GET /rest/posts?author={personUrn}&q=author` | No |
| `comment` | `post_id`, `text` | `POST /rest/socialActions/{id}/comments` | Yes |
| `react` | `post_id`, `reaction_type` (LIKE/CELEBRATE/SUPPORT/LOVE/INSIGHTFUL/FUNNY) | `POST /rest/reactions?actor={actorUrn}` | Yes |
| `delete_post` | `post_id` | `DELETE /rest/posts/{id}` | Yes |
| `get_engagement` | `post_id` | `GET /rest/socialActions/{id}` | No |
| `get_profile` | (none) | `GET /rest/me` | No |
Note: `list_posts` queries posts authored by the authenticated user (not a home feed —
LinkedIn does not expose a home feed API). `get_engagement` returns likes/comments/shares
counts for a specific post via the socialActions endpoint.
### Security enforcement
- Write actions (`create_post`, `comment`, `react`, `delete_post`): check `security.can_act()` + `security.record_action()`
- Read actions (`list_posts`, `get_engagement`, `get_profile`): still call `record_action()` for rate tracking
### Parameter validation
- `article_title` without `article_url` returns error: "article_title requires article_url"
- `react` requires both `post_id` and `reaction_type`
- `comment` requires both `post_id` and `text`
- `create_post` requires `text` (non-empty)
### Parameter schema
```json
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create_post", "list_posts", "comment", "react", "delete_post", "get_engagement", "get_profile"],
"description": "The LinkedIn action to perform"
},
"text": {
"type": "string",
"description": "Post or comment text content"
},
"visibility": {
"type": "string",
"enum": ["PUBLIC", "CONNECTIONS"],
"description": "Post visibility (default: PUBLIC)"
},
"article_url": {
"type": "string",
"description": "URL to attach as article/link preview"
},
"article_title": {
"type": "string",
"description": "Title for the attached article (requires article_url)"
},
"post_id": {
"type": "string",
"description": "LinkedIn post URN for comment/react/delete/engagement"
},
"reaction_type": {
"type": "string",
"enum": ["LIKE", "CELEBRATE", "SUPPORT", "LOVE", "INSIGHTFUL", "FUNNY"],
"description": "Reaction type for the react action"
},
"count": {
"type": "integer",
"description": "Number of posts to retrieve (default 10, max 50)"
}
},
"required": ["action"]
}
```
## LinkedIn client
### `LinkedInClient` struct
```rust
pub struct LinkedInClient {
workspace_dir: PathBuf,
}
```
Uses `crate::config::build_runtime_proxy_client_with_timeouts("tool.linkedin", 30, 10)`
per request (same pattern as Pushover), respecting runtime proxy configuration.
### Credential loading
Same pattern as `PushoverTool`: reads `.env` from `workspace_dir`, parses key-value
pairs, supports `export` prefix and quoted values.
### Token refresh
1. All API calls use `LINKEDIN_ACCESS_TOKEN` in `Authorization: Bearer` header
2. On 401 response, attempt token refresh:
- `POST https://www.linkedin.com/oauth/v2/accessToken`
- Body: `grant_type=refresh_token&refresh_token=...&client_id=...&client_secret=...`
3. On successful refresh, update `LINKEDIN_ACCESS_TOKEN` in `.env` file via
line-targeted replacement (read all lines, replace the matching key line, write back).
Preserves `export` prefixes, quoting style, comments, and all other keys.
4. Retry the original request once
5. If refresh also fails, return error with clear message about re-authentication
### API versioning
All requests include:
- `LinkedIn-Version: 202402` header (stable version)
- `X-Restli-Protocol-Version: 2.0.0` header
- `Content-Type: application/json`
### React endpoint details
The `react` action sends:
- `POST /rest/reactions?actor=urn:li:person:{personId}`
- Body: `{"reactionType": "LIKE", "object": "urn:li:ugcPost:{postId}"}`
The actor URN is derived from `LINKEDIN_PERSON_ID` in `.env`.
### Response parsing
The client returns structured data types:
```rust
pub struct PostSummary {
pub id: String,
pub text: String,
pub created_at: String,
pub visibility: String,
}
pub struct ProfileInfo {
pub id: String,
pub name: String,
pub headline: String,
}
pub struct EngagementSummary {
pub likes: u64,
pub comments: u64,
pub shares: u64,
}
```
## Registration
In `src/tools/mod.rs` (follows `security_ops` config-gated pattern):
```rust
// Module declarations
pub mod linkedin;
pub mod linkedin_client;
// Re-exports
pub use linkedin::LinkedInTool;
// In all_tools_with_runtime():
if root_config.linkedin.enabled {
tool_arcs.push(Arc::new(LinkedInTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)));
}
```
## Config schema
In `src/config/schema.rs`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LinkedInConfig {
pub enabled: bool,
}
impl Default for LinkedInConfig {
fn default() -> Self {
Self { enabled: false }
}
}
```
Added as field `pub linkedin: LinkedInConfig` on the `Config` struct.
Added to `pub use` exports in `src/config/mod.rs`.
## Testing
### Unit tests (in `linkedin.rs`)
- Tool name, description, schema validation
- Action dispatch routes correctly
- Write actions blocked in read-only mode
- Write actions blocked by rate limiting
- Missing required params return clear errors
- Unknown action returns error
- `article_title` without `article_url` returns validation error
### Unit tests (in `linkedin_client.rs`)
- Credential parsing from `.env` (plain, quoted, export prefix, comments)
- Missing credential fields produce specific errors
- Token refresh writes updated token back to `.env` preserving other keys
- Post creation builds correct request body with URN formatting
- React builds correct query param with actor URN
- Visibility defaults to PUBLIC when omitted
### Registry tests (in `mod.rs`)
- `all_tools` excludes `linkedin` when `linkedin.enabled = false`
- `all_tools` includes `linkedin` when `linkedin.enabled = true`
### Integration tests
Not added in this PR — would require live LinkedIn API credentials.
A `#[cfg(feature = "test-linkedin-live")]` gate can be added later.
## Error handling
- Missing `.env` file: "LinkedIn credentials not found. Add LINKEDIN_* keys to .env"
- Missing specific key: "LINKEDIN_ACCESS_TOKEN not found in .env"
- Expired token + no refresh token: "LinkedIn token expired. Re-authenticate or add LINKEDIN_REFRESH_TOKEN to .env"
- `article_title` without `article_url`: "article_title requires article_url to be set"
- API errors: pass through LinkedIn's error message with status code
- Rate limited by LinkedIn: "LinkedIn API rate limit exceeded. Try again later."
- Missing scope: "LinkedIn API returned 403. Ensure your app has the required scopes: w_member_social, r_liteprofile, r_member_social"
## PR metadata
- **Branch:** `feature/linkedin-tool`
- **Title:** `feat(tools): add native LinkedIn integration tool`
- **Risk:** Medium — new tool, external API, no security boundary changes
- **Size target:** M (2 new files ~200-300 lines each, 3-4 modified files)

View File

@@ -0,0 +1,281 @@
# Google Workspace Operation Allowlist
Date: 2026-03-19
Status: Implemented
Scope: `google_workspace` wrapper only
## Problem
The current `google_workspace` tool scopes access only at the service level.
If `gmail` is allowed, the agent can request any Gmail resource and method that
`gws` and the credential authorize. That is too broad for supervised workflows
such as "read and draft, but never send."
This creates a gap between:
- tool-level safety expectations in first-party skills such as `email-assistant`
- actual runtime enforcement in the ZeroClaw wrapper
## Current State
The current wrapper supports:
- `allowed_services`
- `credentials_path`
- `default_account`
- rate limiting
- timeout
- audit logging
It does not currently support:
- declared credential profiles for `google_workspace`
- startup verification of granted OAuth scopes
- separate credential files per trust tier as a first-class config concept
## Goals
- Add a method-level allowlist to the ZeroClaw `google_workspace` wrapper.
- Preserve backward compatibility for existing configs.
- Fail closed when an operation is outside the configured allowlist.
- Make Gmail-native draft workflows possible without exposing send methods in the wrapper.
## Non-Goals
This slice does not attempt to solve credential-level policy gaps in Gmail OAuth.
Specifically, it does not add:
- OAuth scope introspection at startup
- credential profile declarations
- trust-tier routing across multiple credential files
- dynamic operation discovery
Those are valid follow-on items, but they are separate features.
## Proposed Config
Gmail uses a 4-segment gws command shape (`gws gmail users <sub_resource> <method>`),
so `sub_resource` is required for all Gmail entries. Drive and Calendar use
3-segment commands and omit `sub_resource`.
```toml
[google_workspace]
enabled = true
default_account = "owner@company.com"
allowed_services = ["gmail"]
audit_log = true
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "messages"
methods = ["list", "get"]
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "threads"
methods = ["get"]
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "drafts"
methods = ["list", "get", "create", "update"]
```
Semantics:
- If `allowed_operations` is empty, behavior stays backward compatible:
all resource/method combinations remain available within `allowed_services`.
- If `allowed_operations` is non-empty, only exact matches pass. An entry matches
a call when `service`, `resource`, `sub_resource`, and `method` all agree.
`sub_resource` in the entry is optional: an entry without `sub_resource` matches
only calls with no sub_resource; an entry with `sub_resource` matches only calls
with that exact sub_resource value.
- Service-level and operation-level checks both apply.
## Operation Inventory Reference
The first question operators need answered is not "where is the canonical API
inventory?" It is "what string values are valid here?"
For `allowed_operations`, the runtime expects `service`, `resource`, an optional
`sub_resource`, and `methods`. The values come directly from the `gws` command
segments in the same order.
3-segment commands (Drive, Calendar, Sheets, etc.):
```text
gws <service> <resource> <method> ...
```
```toml
[[google_workspace.allowed_operations]]
service = "<service>"
resource = "<resource>"
# sub_resource omitted
methods = ["<method>"]
```
4-segment commands (Gmail and other user-scoped APIs):
```text
gws <service> <resource> <sub_resource> <method> ...
```
```toml
[[google_workspace.allowed_operations]]
service = "<service>"
resource = "<resource>"
sub_resource = "<sub_resource>"
methods = ["<method>"]
```
Examples verified against `gws` discovery output:
| CLI shape | Config entry |
|---|---|
| `gws gmail users messages list` | `service = "gmail"`, `resource = "users"`, `sub_resource = "messages"`, `method = "list"` |
| `gws gmail users drafts create` | `service = "gmail"`, `resource = "users"`, `sub_resource = "drafts"`, `method = "create"` |
| `gws calendar events list` | `service = "calendar"`, `resource = "events"`, `method = "list"` |
| `gws drive files get` | `service = "drive"`, `resource = "files"`, `method = "get"` |
Verified starter examples for common supervised workflows:
- Gmail read-only triage:
- `gmail/users/messages/list`
- `gmail/users/messages/get`
- `gmail/users/threads/list`
- `gmail/users/threads/get`
- Gmail draft-without-send:
- `gmail/users/drafts/list`
- `gmail/users/drafts/get`
- `gmail/users/drafts/create`
- `gmail/users/drafts/update`
- Calendar review:
- `calendar/events/list`
- `calendar/events/get`
- Calendar scheduling:
- `calendar/events/list`
- `calendar/events/get`
- `calendar/events/insert`
- `calendar/events/update`
- Drive lookup:
- `drive/files/list`
- `drive/files/get`
- Drive metadata and sharing review:
- `drive/files/list`
- `drive/files/get`
- `drive/files/update`
- `drive/permissions/list`
Important constraint:
- This spec intentionally documents the value shape and a small set of verified
common examples.
- It does not attempt to freeze a complete global list of every Google
Workspace operation, because the underlying `gws` command surface is derived
from Google's Discovery Service and can evolve over time.
When you need to confirm whether a less-common operation exists:
- Use the Google Workspace CLI docs as the operator-facing entry point:
`https://googleworkspace-cli.mintlify.app/`
- Use the Google API Discovery directory to identify the relevant API:
`https://developers.google.com/discovery/v1/reference/apis/list`
- Use the per-service Discovery document or REST reference to confirm the exact
resource and method names for that API.
## Runtime Enforcement
Validation order inside `google_workspace`:
1. Extract `service`, `resource`, `method` from args (required).
2. Extract and validate `sub_resource` if present (type check, character check).
3. Check rate limits.
4. Check `service` against `allowed_services`.
5. Check `(service, resource, sub_resource, method)` against `allowed_operations`
when configured. Unmatched combinations are denied fail-closed.
6. Validate `service`, `resource`, and `method` for shell-safe characters.
7. Build optional args (`params`, `body`, `format`, `page_all`, `page_limit`).
8. Charge action budget (only after all validation passes).
9. Execute the `gws` command.
This must be fail-closed. A missing operation match is a hard deny, not a warning.
## Data Model
Config type:
```rust
pub struct GoogleWorkspaceAllowedOperation {
pub service: String,
pub resource: String,
pub sub_resource: Option<String>,
pub methods: Vec<String>,
}
```
Added to `GoogleWorkspaceConfig`:
```rust
pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>
```
## Validation Rules
- `service` must be non-empty, lowercase alphanumeric with `_` or `-`
- `resource` must be non-empty, lowercase alphanumeric with `_` or `-`
- `sub_resource`, when present, must be non-empty, lowercase alphanumeric with `_` or `-`
- `methods` must be non-empty
- each method must be non-empty, lowercase alphanumeric with `_` or `-`
- duplicate methods within one entry are rejected by validation
- duplicate `(service, resource, sub_resource)` entries are rejected by validation
## TDD Plan
1. Add config validation tests for invalid `allowed_operations`.
2. Add tool tests for allow-all fallback when `allowed_operations` is empty.
3. Add tool tests for exact allowlist matching.
4. Add tool tests that deny unlisted operations such as `gmail/users/drafts/send`.
5. Implement the config model and runtime checks.
6. Update docs with the new config shape and the Gmail draft-only pattern.
## Example Use Case
For `email-assistant`, the safe Gmail-native draft profile is:
```toml
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "messages"
methods = ["list", "get"]
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "threads"
methods = ["get"]
[[google_workspace.allowed_operations]]
service = "gmail"
resource = "users"
sub_resource = "drafts"
methods = ["list", "get", "create", "update"]
```
Operations denied by omission: `gmail/users/messages/send`, `gmail/users/drafts/send`.
This is not a credential-level send prohibition. It is a runtime boundary inside
the ZeroClaw wrapper.
## Follow-On Work
Future credential-hardening work tracked separately:
1. Declared credential profiles in `google_workspace` config.
2. Startup verification of granted scopes against declared policy.
3. Multiple credential files per trust tier.
4. Optional profile-to-operation binding.