diff --git a/docs/SECURITY-POLICY.md b/docs/SECURITY-POLICY.md index b02ab74..01b7bf0 100644 --- a/docs/SECURITY-POLICY.md +++ b/docs/SECURITY-POLICY.md @@ -180,6 +180,28 @@ Commands matching the safe list are allowed without restriction, **unless** they 6. POST/PUT to untrusted domain → escalate medium → high 7. Domain in allowlist → **ALLOW** (low) +#### Social Account Actions + +Mutating requests to X/Twitter or TweetClaw social account endpoints receive the +`SOCIAL_ACCOUNT_ACTION` risk tag and escalate to **high** risk. These actions can +post tweets, post tweet replies, send direct messages, upload media, create +monitors, register webhooks, or run giveaway draws, so balanced mode prompts the +operator before execution instead of silently allowing the request. + +| Example | Risk | +|---------|------| +| `POST https://api.twitter.com/2/tweets` | high | +| `POST https://xquik.com/api/v1/x/tweets` | high | +| `POST https://xquik.com/api/v1/x/dm/12345` | high | +| `POST https://xquik.com/api/v1/x/media` | high | +| `POST https://xquik.com/api/v1/monitors` | high | +| `POST https://xquik.com/api/v1/webhooks` | high | +| `POST https://xquik.com/api/v1/draws` | high | + +Read-only TweetClaw requests such as tweet search, user lookup, or follower +export remain low risk unless they hit another rule such as secret scanning, +high-risk TLD handling, or webhook exfiltration. + --- ### 4.3 File Operations (`read_file` / `write_file`) diff --git a/skills/agentguard/action-policies.md b/skills/agentguard/action-policies.md index 84776e6..f32fd91 100644 --- a/skills/agentguard/action-policies.md +++ b/skills/agentguard/action-policies.md @@ -51,6 +51,28 @@ Scan request body for sensitive data. Priority determines risk level: 6. POST/PUT to untrusted domain -> escalate medium to high 7. Domain in allowlist -> ALLOW (low) +### Social Account Actions + +Mutating requests to X/Twitter or TweetClaw social account endpoints receive the +`SOCIAL_ACCOUNT_ACTION` risk tag and escalate to high risk. Balanced mode prompts +the operator before execution because these requests can post tweets, post tweet +replies, send direct messages, upload media, create monitors, register webhooks, +or run giveaway draws. + +| Example | Risk | +|---------|------| +| `POST https://api.twitter.com/2/tweets` | high | +| `POST https://xquik.com/api/v1/x/tweets` | high | +| `POST https://xquik.com/api/v1/x/dm/12345` | high | +| `POST https://xquik.com/api/v1/x/media` | high | +| `POST https://xquik.com/api/v1/monitors` | high | +| `POST https://xquik.com/api/v1/webhooks` | high | +| `POST https://xquik.com/api/v1/draws` | high | + +Read-only TweetClaw requests such as tweet search, user lookup, or follower +export remain low risk unless they hit another rule such as secret scanning, +high-risk TLD handling, or webhook exfiltration. + ## Command Execution Detector ### Dangerous Commands (always DENY, critical) diff --git a/src/action/detectors/network.ts b/src/action/detectors/network.ts index 222f747..aa6512f 100644 --- a/src/action/detectors/network.ts +++ b/src/action/detectors/network.ts @@ -61,6 +61,46 @@ const HIGH_RISK_TLDS = [ '.link', ]; +const SOCIAL_ACCOUNT_DOMAINS = [ + 'api.twitter.com', + 'api.x.com', + 'twitter.com', + 'x.com', +]; + +const XQUIK_SOCIAL_ACCOUNT_PATH_PATTERNS = [ + /^\/api\/v1\/x\/tweets(?:\/|$)/, + /^\/api\/v1\/x\/dm(?:\/|$)/, + /^\/api\/v1\/x\/media(?:\/|$)/, + /^\/api\/v1\/x\/profile(?:\/|$)/, + /^\/api\/v1\/x\/users\/[^/]+\/(?:follow|remove-follower)(?:\/|$)/, + /^\/api\/v1\/monitors(?:\/|$)/, + /^\/api\/v1\/webhooks(?:\/|$)/, + /^\/api\/v1\/draws(?:\/|$)/, +]; + +const DIRECT_SOCIAL_ACCOUNT_PATH_PATTERNS = [ + /^\/2\/tweets(?:\/|$)/, + /^\/2\/users\/[^/]+\/(?:following|likes|retweets|muting|blocking)(?:\/|$)/, + /^\/2\/dm_conversations(?:\/|$)/, + /^\/2\/dm_events(?:\/|$)/, + /^\/1\.1\/statuses\/(?:update|destroy|retweet|unretweet)(?:\/|\.json|$)/, + /^\/1\.1\/direct_messages(?:\/|$)/, + /^\/1\.1\/account\/(?:update_profile|update_profile_image|update_profile_banner|remove_profile_banner|settings)(?:\.json|\/|$)/, + /^\/1\.1\/friendships\/(?:create|destroy|update)(?:\.json|\/|$)/, + /^\/1\.1\/favorites\/(?:create|destroy)(?:\.json|\/|$)/, + /^\/1\.1\/blocks\/(?:create|destroy)(?:\.json|\/|$)/, + /^\/1\.1\/mutes\/users\/(?:create|destroy)(?:\.json|\/|$)/, + /^\/1\.1\/media\/upload(?:\.json|\/|$)/, +]; + +const DIRECT_SOCIAL_ACCOUNT_EXCLUDED_PATH_PATTERNS = [ + /^\/2\/oauth2(?:\/|$)/, + /^\/oauth2(?:\/|$)/, + /^\/oauth(?:\/|$)/, + /^\/1\.1\/account\/verify_credentials(?:\.json|\/|$)/, +]; + /** * Analyze a network request for security risks */ @@ -76,6 +116,7 @@ export function analyzeNetworkRequest( const method = normalizeMethod(request.method); const readOnlyMethod = isReadOnlyMethod(method); const mutatingMethod = method === 'POST' || method === 'PUT' || method === 'PATCH'; + const stateChangingMethod = mutatingMethod || method === 'DELETE'; // Extract domain const domain = extractDomain(request.url); @@ -201,6 +242,17 @@ export function analyzeNetworkRequest( riskLevel = maxRisk(riskLevel, 'medium'); } + if (stateChangingMethod && isSocialAccountAction(domain, request.url)) { + riskTags.push('SOCIAL_ACCOUNT_ACTION'); + evidence.push({ + type: 'social_account_action', + field: 'url', + match: request.url, + description: 'Mutating request can change an X/Twitter or TweetClaw social account state', + }); + riskLevel = maxRisk(riskLevel, 'high'); + } + return { risk_level: riskLevel, risk_tags: riskTags, @@ -230,6 +282,26 @@ function isReadOnlyMethod(method: NetworkMethod): boolean { return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'; } +function isSocialAccountAction(domain: string, url: string): boolean { + try { + const parsed = new URL(url); + const pathname = parsed.pathname.toLowerCase(); + if (domain === 'xquik.com' || domain.endsWith('.xquik.com')) { + return XQUIK_SOCIAL_ACCOUNT_PATH_PATTERNS.some((pattern) => pattern.test(pathname)); + } + const matchesKnownSocialDomain = SOCIAL_ACCOUNT_DOMAINS.some( + (knownDomain) => domain === knownDomain || domain.endsWith('.' + knownDomain) + ); + return ( + matchesKnownSocialDomain && + !DIRECT_SOCIAL_ACCOUNT_EXCLUDED_PATH_PATTERNS.some((pattern) => pattern.test(pathname)) && + DIRECT_SOCIAL_ACCOUNT_PATH_PATTERNS.some((pattern) => pattern.test(pathname)) + ); + } catch { + return false; + } +} + const RISK_ORDER: Record = { low: 0, medium: 1, diff --git a/src/tests/action.test.ts b/src/tests/action.test.ts index d662a0f..0cd8ce5 100644 --- a/src/tests/action.test.ts +++ b/src/tests/action.test.ts @@ -235,6 +235,130 @@ describe('Network Request Detector', () => { assert.ok(!result.should_block); }); + it('should require high-risk review for TweetClaw social account writes', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://xquik.com/api/v1/x/tweets', + body_preview: '{"text":"Launch update"}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(result.risk_tags.includes('MUTATING_UNTRUSTED_REQUEST')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for TweetClaw recurring social workflows', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://xquik.com/api/v1/monitors', + body_preview: '{"username":"example","eventTypes":["tweet"]}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for TweetClaw direct messages', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://xquik.com/api/v1/x/dm/12345', + body_preview: '{"text":"hello"}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for TweetClaw profile updates', () => { + const result = analyzeNetworkRequest({ + method: 'PATCH', + url: 'https://xquik.com/api/v1/x/profile', + body_preview: '{"bio":"Approved profile update"}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should keep read-only TweetClaw searches low risk', () => { + const result = analyzeNetworkRequest({ + method: 'GET', + url: 'https://xquik.com/api/v1/x/tweets/search?query=openclaw', + }); + assert.equal(result.risk_level, 'low'); + assert.ok(!result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for direct X mutating requests', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://api.twitter.com/2/tweets', + body_preview: '{"text":"Agent-generated reply"}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should keep direct X non-social mutating requests at generic network risk', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://api.twitter.com/2/oauth2/token', + body_preview: '{"grant_type":"client_credentials"}', + }); + assert.equal(result.risk_level, 'medium'); + assert.ok(result.risk_tags.includes('MUTATING_UNTRUSTED_REQUEST')); + assert.ok(!result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should keep direct X credential verification out of social-action review', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://api.x.com/1.1/account/verify_credentials.json', + body_preview: '{}', + }); + assert.equal(result.risk_level, 'medium'); + assert.ok(result.risk_tags.includes('MUTATING_UNTRUSTED_REQUEST')); + assert.ok(!result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for direct X account updates', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://api.x.com/1.1/account/update_profile.json', + body_preview: '{"description":"Approved update"}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should require high-risk review for direct X tweet deletes', () => { + const result = analyzeNetworkRequest({ + method: 'DELETE', + url: 'https://api.x.com/2/tweets/12345', + body_preview: '{}', + }); + assert.equal(result.risk_level, 'high'); + assert.ok(result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + + it('should keep direct X compliance jobs out of social-action review', () => { + const result = analyzeNetworkRequest({ + method: 'POST', + url: 'https://api.x.com/2/compliance/jobs', + body_preview: '{"type":"tweets","name":"audit"}', + }); + assert.equal(result.risk_level, 'medium'); + assert.ok(result.risk_tags.includes('MUTATING_UNTRUSTED_REQUEST')); + assert.ok(!result.risk_tags.includes('SOCIAL_ACCOUNT_ACTION')); + assert.ok(!result.should_block); + }); + it('should normalize lowercase mutating request methods', () => { const postResult = analyzeNetworkRequest({ method: 'post' as NetworkRequestData['method'],