Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/SECURITY-POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
22 changes: 22 additions & 0 deletions skills/agentguard/action-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions src/action/detectors/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<NetworkRiskLevel, number> = {
low: 0,
medium: 1,
Expand Down
124 changes: 124 additions & 0 deletions src/tests/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading