Skip to content

Commit f658e6d

Browse files
authored
fix(tailscale): align tool coverage and outputs with the Tailscale API (#5366)
* fix(tailscale): align tool coverage and outputs with the Tailscale API - fix list_users profilePicUrl field name (API returns lowercase, was always null) - add nodeId, keyExpiryDisabled, expires to device outputs - quote the If-Match header value on ACL updates per API spec - add set_acl, expire_device_key, suspend_user, delete_user tools - add wandConfig to dnsServers/searchPaths block fields - expand BlockMeta skills/templates for ACL and key-expiry workflows * fix(tailscale): remove phantom magicDNS field from list_dns_nameservers The GET /tailnet/{tailnet}/dns/nameservers response only returns {dns: string[]} per the API spec — magicDNS is not part of this endpoint's response and was always silently false. * fix(tailscale): extend ToolResponse in expire_device_key response type Matches the pattern used by the other new tools in this PR (delete_user, suspend_user). * fix(tailscale): list_auth_keys now returns all tailnet keys GET /tailnet/{tailnet}/keys silently scopes to the caller's own keys unless all=true is passed, contradicting the tool's stated purpose of listing all auth keys in the tailnet.
1 parent 5966e5c commit f658e6d

13 files changed

Lines changed: 443 additions & 13 deletions

apps/sim/blocks/blocks/tailscale.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,22 @@ export const TailscaleBlock: BlockConfig = {
2929
{ label: 'Get Device Routes', id: 'get_device_routes' },
3030
{ label: 'Set Device Routes', id: 'set_device_routes' },
3131
{ label: 'Update Device Key', id: 'update_device_key' },
32+
{ label: 'Expire Device Key', id: 'expire_device_key' },
3233
{ label: 'List DNS Nameservers', id: 'list_dns_nameservers' },
3334
{ label: 'Set DNS Nameservers', id: 'set_dns_nameservers' },
3435
{ label: 'Get DNS Preferences', id: 'get_dns_preferences' },
3536
{ label: 'Set DNS Preferences', id: 'set_dns_preferences' },
3637
{ label: 'Get DNS Search Paths', id: 'get_dns_searchpaths' },
3738
{ label: 'Set DNS Search Paths', id: 'set_dns_searchpaths' },
3839
{ label: 'List Users', id: 'list_users' },
40+
{ label: 'Suspend User', id: 'suspend_user' },
41+
{ label: 'Delete User', id: 'delete_user' },
3942
{ label: 'Create Auth Key', id: 'create_auth_key' },
4043
{ label: 'List Auth Keys', id: 'list_auth_keys' },
4144
{ label: 'Get Auth Key', id: 'get_auth_key' },
4245
{ label: 'Delete Auth Key', id: 'delete_auth_key' },
4346
{ label: 'Get ACL', id: 'get_acl' },
47+
{ label: 'Set ACL', id: 'set_acl' },
4448
],
4549
value: () => 'list_devices',
4650
},
@@ -74,6 +78,7 @@ export const TailscaleBlock: BlockConfig = {
7478
'get_device_routes',
7579
'set_device_routes',
7680
'update_device_key',
81+
'expire_device_key',
7782
],
7883
},
7984
required: {
@@ -86,6 +91,7 @@ export const TailscaleBlock: BlockConfig = {
8691
'get_device_routes',
8792
'set_device_routes',
8893
'update_device_key',
94+
'expire_device_key',
8995
],
9096
},
9197
},
@@ -144,6 +150,11 @@ export const TailscaleBlock: BlockConfig = {
144150
placeholder: '8.8.8.8,8.8.4.4',
145151
condition: { field: 'operation', value: 'set_dns_nameservers' },
146152
required: { field: 'operation', value: 'set_dns_nameservers' },
153+
wandConfig: {
154+
enabled: true,
155+
prompt:
156+
'Generate a comma-separated list of DNS nameserver IP addresses (e.g., 8.8.8.8,8.8.4.4). Return ONLY the comma-separated IP addresses - no explanations, no extra text.',
157+
},
147158
},
148159
{
149160
id: 'magicDNS',
@@ -163,6 +174,11 @@ export const TailscaleBlock: BlockConfig = {
163174
placeholder: 'corp.example.com,internal.example.com',
164175
condition: { field: 'operation', value: 'set_dns_searchpaths' },
165176
required: { field: 'operation', value: 'set_dns_searchpaths' },
177+
wandConfig: {
178+
enabled: true,
179+
prompt:
180+
'Generate a comma-separated list of DNS search path domains (e.g., corp.example.com,internal.example.com). Return ONLY the comma-separated domains - no explanations, no extra text.',
181+
},
166182
},
167183
{
168184
id: 'keyId',
@@ -224,6 +240,30 @@ export const TailscaleBlock: BlockConfig = {
224240
condition: { field: 'operation', value: 'create_auth_key' },
225241
mode: 'advanced',
226242
},
243+
{
244+
id: 'userId',
245+
title: 'User ID',
246+
type: 'short-input',
247+
placeholder: 'Enter user ID',
248+
condition: { field: 'operation', value: ['suspend_user', 'delete_user'] },
249+
required: { field: 'operation', value: ['suspend_user', 'delete_user'] },
250+
},
251+
{
252+
id: 'acl',
253+
title: 'ACL Policy',
254+
type: 'long-input',
255+
placeholder: '{"acls": [{"action": "accept", "users": ["*"], "ports": ["*:*"]}]}',
256+
condition: { field: 'operation', value: 'set_acl' },
257+
required: { field: 'operation', value: 'set_acl' },
258+
},
259+
{
260+
id: 'ifMatch',
261+
title: 'If-Match ETag',
262+
type: 'short-input',
263+
placeholder: 'ETag from Get ACL, or "ts-default"',
264+
condition: { field: 'operation', value: 'set_acl' },
265+
mode: 'advanced',
266+
},
227267
],
228268

229269
tools: {
@@ -236,18 +276,22 @@ export const TailscaleBlock: BlockConfig = {
236276
'tailscale_get_device_routes',
237277
'tailscale_set_device_routes',
238278
'tailscale_update_device_key',
279+
'tailscale_expire_device_key',
239280
'tailscale_list_dns_nameservers',
240281
'tailscale_set_dns_nameservers',
241282
'tailscale_get_dns_preferences',
242283
'tailscale_set_dns_preferences',
243284
'tailscale_get_dns_searchpaths',
244285
'tailscale_set_dns_searchpaths',
245286
'tailscale_list_users',
287+
'tailscale_suspend_user',
288+
'tailscale_delete_user',
246289
'tailscale_create_auth_key',
247290
'tailscale_list_auth_keys',
248291
'tailscale_get_auth_key',
249292
'tailscale_delete_auth_key',
250293
'tailscale_get_acl',
294+
'tailscale_set_acl',
251295
],
252296
config: {
253297
tool: (params) => `tailscale_${params.operation}`,
@@ -258,10 +302,13 @@ export const TailscaleBlock: BlockConfig = {
258302
}
259303
if (params.deviceId) mapped.deviceId = params.deviceId
260304
if (params.keyId) mapped.keyId = params.keyId
305+
if (params.userId) mapped.userId = params.userId
261306
if (params.tags) mapped.tags = params.tags
262307
if (params.routes) mapped.routes = params.routes
263308
if (params.dnsServers) mapped.dns = params.dnsServers
264309
if (params.searchPaths) mapped.searchPaths = params.searchPaths
310+
if (params.acl) mapped.acl = params.acl
311+
if (params.ifMatch) mapped.ifMatch = params.ifMatch
265312
if (params.authorized !== undefined) mapped.authorized = params.authorized === 'true'
266313
if (params.keyExpiryDisabled !== undefined)
267314
mapped.keyExpiryDisabled = params.keyExpiryDisabled === 'true'
@@ -282,6 +329,9 @@ export const TailscaleBlock: BlockConfig = {
282329
tailnet: { type: 'string', description: 'Tailnet name' },
283330
deviceId: { type: 'string', description: 'Device ID' },
284331
keyId: { type: 'string', description: 'Auth key ID' },
332+
userId: { type: 'string', description: 'User ID' },
333+
acl: { type: 'string', description: 'ACL policy file as a JSON string' },
334+
ifMatch: { type: 'string', description: 'ETag for optimistic concurrency on ACL updates' },
285335
authorized: { type: 'string', description: 'Authorization status' },
286336
keyExpiryDisabled: { type: 'string', description: 'Whether to disable key expiry' },
287337
tags: { type: 'string', description: 'Comma-separated tags' },
@@ -300,6 +350,7 @@ export const TailscaleBlock: BlockConfig = {
300350
devices: { type: 'json', description: 'List of devices in the tailnet' },
301351
count: { type: 'number', description: 'Total count of items returned' },
302352
id: { type: 'string', description: 'Device or auth key ID' },
353+
nodeId: { type: 'string', description: 'Preferred device ID' },
303354
name: { type: 'string', description: 'Device name' },
304355
hostname: { type: 'string', description: 'Device hostname' },
305356
user: { type: 'string', description: 'Associated user' },
@@ -331,11 +382,12 @@ export const TailscaleBlock: BlockConfig = {
331382
key: { type: 'string', description: 'Auth key value (only at creation)' },
332383
keyId: { type: 'string', description: 'Auth key ID' },
333384
description: { type: 'string', description: 'Auth key description' },
334-
expires: { type: 'string', description: 'Expiration timestamp' },
385+
expires: { type: 'string', description: 'Device key or auth key expiration timestamp' },
335386
revoked: { type: 'string', description: 'Revocation timestamp' },
336387
capabilities: { type: 'json', description: 'Auth key capabilities' },
337388
acl: { type: 'string', description: 'ACL policy as JSON string' },
338389
etag: { type: 'string', description: 'ACL ETag for conditional updates' },
390+
userId: { type: 'string', description: 'User ID' },
339391
},
340392
}
341393

@@ -356,7 +408,7 @@ export const TailscaleBlockMeta = {
356408
icon: TailscaleIcon,
357409
title: 'Tailscale ACL drift detector',
358410
prompt:
359-
'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift, and writes the drift report to Slack.',
411+
'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift to Slack, and, on approval, pushes the corrected policy back with Set ACL.',
360412
modules: ['scheduled', 'agent', 'workflows'],
361413
category: 'engineering',
362414
tags: ['devops', 'monitoring'],
@@ -376,7 +428,7 @@ export const TailscaleBlockMeta = {
376428
icon: TailscaleIcon,
377429
title: 'Tailscale offboarder',
378430
prompt:
379-
"Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, and writes the security audit log.",
431+
"Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, suspends their tailnet user account, and writes the security audit log.",
380432
modules: ['agent', 'workflows'],
381433
category: 'operations',
382434
tags: ['hr', 'enterprise'],
@@ -429,9 +481,24 @@ export const TailscaleBlockMeta = {
429481
},
430482
{
431483
name: 'offboard-device',
432-
description: 'Deauthorize or remove a departing user device and revoke its auth keys.',
484+
description:
485+
"Deauthorize or remove a departing user's devices and auth keys, then suspend or delete their tailnet account.",
486+
content:
487+
'# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Use List Users to find the userId, then Suspend User to freeze access reversibly, or Delete User to remove the account entirely.\n5. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted, the auth keys were revoked, and the user account was suspended or deleted for the offboarding audit log.',
488+
},
489+
{
490+
name: 'update-tailnet-acl',
491+
description:
492+
'Push an updated ACL policy file to the tailnet using ETag-guarded writes to avoid clobbering concurrent edits.',
493+
content:
494+
'# Update the Tailnet ACL as Policy-as-Code\n\nApply a reviewed ACL change programmatically instead of editing it by hand in the admin console.\n\n## Steps\n1. Use Get ACL to fetch the current policy file and its etag.\n2. Compute or generate the new policy JSON from your source of truth (git, agent-authored rules, etc.).\n3. Use Set ACL with the new ACL Policy JSON, passing the etag from step 1 in If-Match to guard against concurrent updates (use "ts-default" instead if you only want to replace an untouched default policy).\n4. If the write fails with a precondition error, re-fetch the ACL and retry.\n\n## Output\nReturn the updated ACL JSON and its new etag, plus a summary of what changed for the change-management record.',
495+
},
496+
{
497+
name: 'lock-down-compromised-device',
498+
description:
499+
"Immediately expire a suspected-compromised device's node key so it must re-authenticate before rejoining the tailnet.",
433500
content:
434-
'# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted and list the revoked auth keys for the offboarding audit log.',
501+
"# Lock Down a Compromised Device\n\nCut off a device the moment it looks compromised, without waiting for its key to expire naturally.\n\n## Steps\n1. Use Get Device or List Devices to confirm the deviceId and review its tags, addresses, and lastSeen.\n2. Use Expire Device Key to immediately invalidate the device's node key so it can no longer connect until it re-authenticates.\n3. For a harder block, follow up with Authorize Device set to Deauthorize, or Delete Device to remove it outright.\n4. Log the deviceId, hostname, and user in the incident record.\n\n## Output\nConfirm the key was expired (and the device deauthorized/deleted if applicable) for the security incident log.",
435502
},
436503
],
437504
} as const satisfies BlockMeta

apps/sim/tools/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3731,6 +3731,8 @@ import {
37313731
tailscaleCreateAuthKeyTool,
37323732
tailscaleDeleteAuthKeyTool,
37333733
tailscaleDeleteDeviceTool,
3734+
tailscaleDeleteUserTool,
3735+
tailscaleExpireDeviceKeyTool,
37343736
tailscaleGetAclTool,
37353737
tailscaleGetAuthKeyTool,
37363738
tailscaleGetDeviceRoutesTool,
@@ -3741,11 +3743,13 @@ import {
37413743
tailscaleListDevicesTool,
37423744
tailscaleListDnsNameserversTool,
37433745
tailscaleListUsersTool,
3746+
tailscaleSetAclTool,
37443747
tailscaleSetDeviceRoutesTool,
37453748
tailscaleSetDeviceTagsTool,
37463749
tailscaleSetDnsNameserversTool,
37473750
tailscaleSetDnsPreferencesTool,
37483751
tailscaleSetDnsSearchpathsTool,
3752+
tailscaleSuspendUserTool,
37493753
tailscaleUpdateDeviceKeyTool,
37503754
} from '@/tools/tailscale'
37513755
import { tavilyCrawlTool, tavilyExtractTool, tavilyMapTool, tavilySearchTool } from '@/tools/tavily'
@@ -5215,18 +5219,22 @@ export const tools: Record<string, ToolConfig> = {
52155219
tailscale_get_device_routes: tailscaleGetDeviceRoutesTool,
52165220
tailscale_set_device_routes: tailscaleSetDeviceRoutesTool,
52175221
tailscale_update_device_key: tailscaleUpdateDeviceKeyTool,
5222+
tailscale_expire_device_key: tailscaleExpireDeviceKeyTool,
52185223
tailscale_list_dns_nameservers: tailscaleListDnsNameserversTool,
52195224
tailscale_set_dns_nameservers: tailscaleSetDnsNameserversTool,
52205225
tailscale_get_dns_preferences: tailscaleGetDnsPreferencesTool,
52215226
tailscale_set_dns_preferences: tailscaleSetDnsPreferencesTool,
52225227
tailscale_get_dns_searchpaths: tailscaleGetDnsSearchpathsTool,
52235228
tailscale_set_dns_searchpaths: tailscaleSetDnsSearchpathsTool,
52245229
tailscale_list_users: tailscaleListUsersTool,
5230+
tailscale_suspend_user: tailscaleSuspendUserTool,
5231+
tailscale_delete_user: tailscaleDeleteUserTool,
52255232
tailscale_create_auth_key: tailscaleCreateAuthKeyTool,
52265233
tailscale_list_auth_keys: tailscaleListAuthKeysTool,
52275234
tailscale_get_auth_key: tailscaleGetAuthKeyTool,
52285235
tailscale_delete_auth_key: tailscaleDeleteAuthKeyTool,
52295236
tailscale_get_acl: tailscaleGetAclTool,
5237+
tailscale_set_acl: tailscaleSetAclTool,
52305238
calendly_get_current_user: calendlyGetCurrentUserTool,
52315239
calendly_list_event_types: calendlyListEventTypesTool,
52325240
calendly_get_event_type: calendlyGetEventTypeTool,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { ToolConfig, ToolResponse } from '@/tools/types'
2+
import type { TailscaleBaseParams } from './types'
3+
4+
interface TailscaleDeleteUserParams extends TailscaleBaseParams {
5+
userId: string
6+
}
7+
8+
interface TailscaleDeleteUserResponse extends ToolResponse {
9+
output: {
10+
success: boolean
11+
userId: string
12+
}
13+
}
14+
15+
export const tailscaleDeleteUserTool: ToolConfig<
16+
TailscaleDeleteUserParams,
17+
TailscaleDeleteUserResponse
18+
> = {
19+
id: 'tailscale_delete_user',
20+
name: 'Tailscale Delete User',
21+
description: 'Delete a user from the tailnet',
22+
version: '1.0.0',
23+
24+
params: {
25+
apiKey: {
26+
type: 'string',
27+
required: true,
28+
visibility: 'user-only',
29+
description: 'Tailscale API key',
30+
},
31+
tailnet: {
32+
type: 'string',
33+
required: true,
34+
visibility: 'user-or-llm',
35+
description: 'Tailnet name (e.g., example.com) or "-" for default',
36+
},
37+
userId: {
38+
type: 'string',
39+
required: true,
40+
visibility: 'user-or-llm',
41+
description: 'User ID to delete',
42+
},
43+
},
44+
45+
request: {
46+
url: (params) =>
47+
`https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/delete`,
48+
method: 'POST',
49+
headers: (params) => ({
50+
Authorization: `Bearer ${params.apiKey.trim()}`,
51+
}),
52+
},
53+
54+
transformResponse: async (response: Response, params?: TailscaleDeleteUserParams) => {
55+
if (!response.ok) {
56+
const data = await response.json().catch(() => ({}))
57+
return {
58+
success: false,
59+
output: { success: false, userId: '' },
60+
error: (data as Record<string, string>).message ?? 'Failed to delete user',
61+
}
62+
}
63+
64+
return {
65+
success: true,
66+
output: {
67+
success: true,
68+
userId: params?.userId ?? '',
69+
},
70+
}
71+
},
72+
73+
outputs: {
74+
success: { type: 'boolean', description: 'Whether the user was successfully deleted' },
75+
userId: { type: 'string', description: 'ID of the deleted user' },
76+
},
77+
}

0 commit comments

Comments
 (0)