/** * AI Guardrails Service * * Tiered permission system for AI tool execution: * - Tier 1: Auto-execute (read-only tools) * - Tier 2: Auto-execute + audit (low-risk mutations) * - Tier 3: Requires user approval (destructive/mutating operations) * - Tier 4: Blocked (auth/user/role modifications, cross-org access) * * Also enforces RBAC permission checks and per-tool rate limiting. */ import type { AiApprovalScope } from '@breeze/shared/types/ai'; import type { AiAgentMode, AiAgentProtectedResources, RiskTier } from '@breeze/shared'; import { getToolTier } from './aiTools'; import { getUserPermissions, hasPermission } from './permissions'; import { rateLimiter } from './rate-limit'; import { TOOL_RATE_LIMITS, resolveToolRateLimitMultiplier, scaleToolRateLimit, type ToolRateLimitScope } from './aiToolRateLimits'; export { listEffectiveToolRateLimits, type EffectiveToolRateLimit } from './aiToolRateLimits'; import { getRedis } from './redis'; import { humanizeToolAction } from './actionIntents/actionLabel'; import { isSecretBearingTool } from './actionIntents/secretBearingTools'; import { WORKSPACE_TOOL_NAMES } from './workspace/workspaceToolNames'; import { isFlowToolName } from './flows/toolNames'; import type { AuthContext } from '../middleware/auth'; import { isAutomationPrincipal, isFlowPrincipal } from '../middleware/principalKind'; import { envFlag } from '../config/env'; import { resolveActOperation } from './aiAgents/actManifest'; import { warrantyHpCmslRequested } from '@breeze/shared/validators'; import { getCachedAiKillStateSnapshot } from './aiKillState'; import { AGENT_COORDINATOR_CARRIER_TOOLS, AGENT_HUMAN_ONLY_ACTIONS, AGENT_HUMAN_ONLY_TOOLS, AGENT_DENIED_READ_TOOLS } from './aiToolExposure'; // Leaf data module (no imports) — the specialist kind table, so the tier // tables below derive from it instead of re-typing kind names. import { ASK_FIRST_SPECIALIST_KINDS, AUTO_ADMIT_SPECIALIST_KINDS, SPECIALIST_KIND_LABELS, isSpecialistKind, } from './wisp/specialistPolicy'; import { LIVE_READ_INPUT_FLAGS, requestsLiveDeviceRead } from './aiLiveDeviceReads'; import { classifyChatReadOnlyEventLogsQuery, type PinnedEventLogsQuery } from './aiChatEventLogsReadOnly'; // Env-only operator knob; the module imports nothing but @breeze/shared. import { iocHuntFourEyesDeviceThreshold } from './securityScanSweepConfig'; import { IOC_HUNT_RUN_ACTION } from './iocHuntActions'; // Re-exported so every existing importer of AGENT_HUMAN_ONLY_TOOLS keeps // working unchanged after the move to aiToolExposure.ts (W01 quorum // amendment WQ4, #6755): the two agent-denial registries now live in one // data-only file, enforced here. export { AGENT_HUMAN_ONLY_ACTIONS, AGENT_HUMAN_ONLY_TOOLS, AGENT_DENIED_READ_TOOLS }; // Re-exported for the tier-parity contract tests, which read every tier table // from this module. export { LIVE_READ_INPUT_FLAGS, requestsLiveDeviceRead }; type AiToolTier = 1 | 2 | 3 | 4; // Tools that are always blocked (Tier 4) export const BLOCKED_TOOLS: ReadonlySet = new Set([ // No tools are explicitly blocked at the tool level — // cross-org access is enforced by orgCondition in each handler ]); // Sub-operation discriminator key per tool. Most action-multiplexed tools // carry their sub-operation in `action`; execute_command multiplexes on // `commandType` (#3088) — that string selects the agent-side command handler, // so it is a real dispatch discriminator, not a heuristic over command text. // The TIER1/2/3_ACTIONS tables are indexed by the value found under this key. // Exported for the tier-parity contract tests (aiGuardrailsTierParity.shared.ts). export const TOOL_ACTION_INPUT_KEYS: Record = { execute_command: 'commandType', // Wisp W07: launch_specialist is multiplexed on the specialist KIND. launch_specialist: 'kind', }; // Actions that are Tier 2 (auto-execute + audit): // manage_alerts: acknowledge/resolve/suppress are low-risk mutations // manage_services: list is a read downgraded from the tool's base Tier 3 // Exported for contract tests only (tier-table disjointness + the web // tierConfig.ts parity guard, issue #2686). Not part of the runtime API — // resolution always goes through checkGuardrails(). export const TIER2_ACTIONS: Record = { // Wisp W07 (spec §3 "acts alone"): the auto-admit specialist kinds, derived // from SPECIALIST_KIND_POLICY (leaf data module). launch_specialist: [...AUTO_ADMIT_SPECIALIST_KINDS], manage_policy_feature_link: ['describe'], // EDR response actions (#8167 W03, spec §4.10): a scan is non-disruptive, // so it runs without approval. The tool's base tier is 3, so every OTHER // action — isolate, release, and any value not offered yet (no adapter // implements the agent-update or process-kill actions; the approvalScope // contract forbids classifying actions outside the real enum) — stays // Tier 3 / four_eyes. edr_endpoint_action: ['scan'], manage_alerts: ['acknowledge', 'resolve', 'suppress'], manage_tickets: [ 'create', 'comment', 'assign', 'update_status', 'update_fields', 'link_alert', 'unlink_alert', 'create_from_alert', // edit_comment/delete_comment moved to TIER3_ACTIONS (W02, #6756): a // public comment's text changes or disappears in the portal with no // restore path (ticketService.ts editTicketComment / deleteTicketComment). // // comment, update_status and update_fields stay here: their BASE class is // tier 2, and isInputAwareTier3 escalates the customer-reaching inputs // (public comment, resolve, requester repoint) on top. // // Time-tracking downgraded from Tier 3 (2026-07-20): starting/stopping a // timer or logging time is org-internal bookkeeping, consistent with // create/comment above. move_org stays Tier 3 (tenant-shape mutation). 'log_time_entry', 'start_timer', 'stop_timer', 'update_time_entry', // P2-4 (#4191) ticket triage: same family as update_fields — low-risk, // ticket-scoped mutations an autonomous triage run makes. link_device // only sets a currently-null device_id (never overwrites); draft only // writes an internal ticket_drafts row, never a customer-visible comment. 'link_device', 'draft', // #8596: ticket-to-ticket links. Same tier as comment/assign: internal, // reversible, bounded to one org (the service refuses cross-org links). // Listing them here is also what denies them to a ticket-triggered // (device-less) agent run — ticketShadowGuardrail.contract.test.ts // derives its mutation set from this list. 'link_ticket', 'unlink_ticket', // Full-control W06 (#6776, W06-D4): assign / status (not resolved) / priority over up to // 50 tickets of ONE organization (the handler refuses a mixed-org batch). Never a delete. 'bulk_update' ], // #6930: ticket-scoped checklist step edits, same family as update_fields. // Ticking a step is not a tool action at all (see aiToolsTicketing.ts). manage_ticket_checklist: ['add_item', 'update_item', 'delete_item', 'reorder', 'apply_template', 'save_as_template', 'duplicate_template'], // Full-control W06 (#6776) tier-2 rows (plan "Final per-action classes"): // - an ORG-owned checklist template create (a partner-wide one escalates through // isInputAwareTier3); // - the inbox list: attacker-authored mail, plain tier 2 so an agent needs an // allowlist entry (a read-only resolution would skip it, quorum QW1); // - attachment content read and attach-from-artifact (a pending, invisible row); // - your own time suggestions: confirm logs a time entry (bookkeeping, like // log_time_entry), dismiss/undo are an inverse pair. manage_ticket_checklist_templates: ['create'], manage_ticket_inbox: ['list'], manage_ticket_attachments: ['read_content', 'attach_from_artifact'], manage_time_suggestions: ['confirm', 'dismiss', 'undo'], // Full-control W07 (#6777): alert correlation + signal triage. Group ack/resolve is the // manage_alerts:acknowledge/resolve precedent over the members of ONE organization's group; // feedback / rca_feedback are ML labels; anomaly dismiss/resolve set a status on one row. // `promote` is NOT here: it raises a real alert (TIER3_SUPERVISED_ACTIONS). manage_alert_correlations: ['acknowledge_group', 'resolve_group', 'acknowledge_related', 'feedback', 'rca_feedback'], manage_device_anomalies: ['dismiss', 'resolve'], // Full-control W08 (#6778): network topology. Starting a diagnostic dispatches a bounded probe to a device // (inline, in the person's own session); the monitoring/manual/template-draft writes are tenant-internal // rows with a revision compare-and-swap; a template PREVIEW persists a short-lived record only. // Publishing / revoking a template and applying one are supervised (TIER3_SUPERVISED_ACTIONS). run_topology_diagnostic: ['start', 'cancel'], manage_topology_manual: ['create_node', 'update_node', 'delete_node', 'create_relationship', 'delete_relationship'], manage_topology_monitoring: ['upsert_policy', 'delete_policy', 'upsert_target', 'delete_target', 'patch_settings'], manage_topology_templates: ['create_template', 'update_template', 'create_version'], apply_topology_template: ['preview'], // Full-control W09 (#6779): SNMP templates and known guests are tenant-internal rows, reversible (the delete result carries // the removed row). probe_network_asset and trigger_unifi_sync are tier 2 as whole tools (TOOL_TIERS). manage_snmp_templates: ['create', 'update', 'delete'], manage_known_guests: ['add', 'delete'], // Full-control W11 (#6781): reversible, tenant-internal bookkeeping. A recommendation status is one audit row // (reopen undoes it); an access-review item decision is recorded only (completing the review, which revokes, // is human-only and absent from the tool); an audit-baseline apply request is one pending row a different // person must approve. Every other W11 write is tier 3 (TIER3_ACTIONS). manage_security_recommendations: ['complete', 'dismiss'], manage_access_reviews: ['decide_item'], manage_audit_baselines: ['request_apply'], // Full-control W12 (#6782): tenant-internal, reversible. A new catalog item is inert until something references it; // a metadata edit changes no install target; cancel stops pending installs (create/retry redo them); a fleet-finding // status is undone by reopen; an org custom-field definition create/edit is undone by another edit. A PARTNER-WIDE // catalog item or definition create is supervised (input-aware, quorum QW1); the tool never edits an existing // partner-wide row (W12-D10). Install-target edits, deploys, inventory policy writes and value writes are tier 3. manage_software_catalog: ['create', 'update', 'import_package'], manage_software_deployments: ['cancel'], manage_fleet_findings: ['acknowledge', 'dismiss', 'reopen'], manage_custom_fields: ['create_definition', 'update_definition'], manage_services: ['list'], // Live device inspection: listing processes / scheduled tasks runs a live // command on the device (devices:execute, like GET /system-tools/...). Tier 2 // read-only, like file_operations:list and manage_services:list: no prompt, // but an audit row, and an MCP key needs ai:write. manage_processes: ['list'], manage_scheduled_tasks: ['list'], // Asking the device for its OS-native cleaner catalog is a live command // (POST /devices/:id/filesystem/system-cleanup/list needs devices:execute). // `status` reads the stored run and stays Tier 1 (TIER1_READ_ACTIONS). system_cleanup: ['list'], // Privileged-read rule, partial relaxation (2026-07-20): directory LISTING is recon-only — // filenames leak far less than contents — so it auto-executes with audit. // file READ stays Tier 3 below: the agent runs as root/LocalSystem and an // unapproved read can exfiltrate any file's contents. file_operations: ['list'], // #3088 approval-fatigue fix (2026-08-04): read-only execute_command // commandTypes are non-mutating device reads and auto-execute with audit, // consistent with their sibling tools (manage_processes list, // manage_services list and file_operations list are Tier 2). This is an // explicit conservative allowlist keyed on commandType (the agent-side // handler discriminator — see TOOL_ACTION_INPUT_KEYS above); anything not // listed here, including an unknown or missing commandType, stays at the // tool's base Tier 3. // // Deliberately NOT downgraded, despite being nominally "read" operations: // - file_read: arbitrary-file exfiltration off a root/LocalSystem agent // (same privileged-read rationale as file_operations read). // - kill_process, start/stop/restart_service: mutating. // - list_services: agent/internal/remote/tools/services_{windows,linux}.go // populates ServiceInfo.Path from the service's full binary path/command // line (config.BinaryPathName / ExecStart), only length-truncated, never // credential-redacted. Legacy and third-party services routinely embed // secrets there (e.g. `-p `, DB connection strings) — same // exfiltration class as file_read. // - event_logs_query: logName is a free-form, caller-controlled string // with no denylist excluding Security — combined with the raw, unredacted // Message field (agent/internal/remote/tools/eventlogs_windows.go), a // Security-log query can surface a mistyped password landing in a 4625 // failed-logon Account Name, or any credential/PII another app logged. // Both would let an AI actor pull that content into its context with zero // human review under auto_approve session mode. // // #7906 narrows the event_logs_query case WITHOUT a static entry here: in // an interactive chat session only, a System/Setup query with no XPath and // a strictly-validated payload resolves Tier 2 read-only through the // payload-aware classifier (aiChatEventLogsReadOnly.ts, wired in // checkGuardrails via GuardrailContext.chatSession). Every other channel, // any XPath, and every non-chat principal keep the base Tier 3. execute_command: [ 'event_logs_list', 'file_list', 'list_processes', ], // Fleet tools — Tier 2 actions (auto-execute + audit) manage_configuration_policy: ['activate', 'deactivate'], manage_deployments: ['pause', 'resume'], // Privileged-read rule (2026-09-17 audit §2.4): registry reads are privileged agent // executions, not device reads — Tier 1 gave them no approval gate AND no // MFA. Raised alongside their devices:execute mapping below. Deliberately // NOT added to TIER2_READONLY_ACTIONS: unlike file_operations.list these // return VALUES, so they keep the per-step prompt. registry_operations: ['read_key', 'get_value'], // scan downgraded from Tier 3 (2026-07-20): discovery, not mutation — // consistent with approve/decline/defer here. install/rollback stay Tier 3. manage_patches: ['approve', 'decline', 'defer', 'bulk_approve', 'scan'], // #6268: stopping an IOC sweep is the safe direction (queued scans are // cancelled, running ones finish), so it auto-executes with an audit row. ioc_hunt: ['cancel_sweep'], manage_groups: ['add_devices', 'remove_devices'], // manage_maintenance_windows mutations disabled — managed via configuration policies manage_automations: ['enable', 'disable'], // manage_alert_rules is read-only; author conditions via manage_monitor_definitions // manage_service_monitors mutations disabled — managed via configuration policies generate_report: ['create', 'update', 'delete', 'generate'], // Policy prerequisite tools — Tier 2 create/update actions. // // #3552 (2026-08-14): manage_update_rings, manage_software_policies and // manage_peripheral_policies were REMOVED from this list and escalated to // Tier 3 in TIER3_ACTIONS below — their create/update payloads ARM // unattended enforcement/remediation on the fleet. The two backup tools stay // here deliberately; see the note in TIER3_ACTIONS for the full rationale. manage_backup_configs: ['create', 'update'], // W03 (#6757): update/delete moved to TIER3_ACTIONS (supervised, W03-D1); // create stays here as the base class and escalates through // isInputAwareTier3 when ownerScope === 'partner'. manage_backup_profiles: ['create'], // Notification channel & saved filter tools — Tier 2 actions. // manage_notification_channels `test` stays tier 2 ONLY because the AI // handler is a lookup stub that sends nothing (W02-D9, pinned by // aiToolsAlerts.channelSecrets.test.ts); create/update/delete are tier 3 // below (W02, #6756). manage_notification_channels: ['test'], manage_saved_filters: ['create', 'delete'], }; // #3130: Tier-2 entries that are strictly READ-ONLY. Tier 2 as a whole means // "low-risk mutations + audit", so the per_step approval mode still prompts // for it — but a verified read has nothing to confirm, and prompting per list // call is exactly the approval-fatigue scenario #3088 measured (25 prompts in // 35 minutes). Entries here keep Tier 2 — and its ai_tool_executions // audit-ledger row; they are deliberately NOT demoted to Tier 1, which never // writes one (recon reads stay in the audit trail, privileged-read precedent) — but // auto-execute under every session approval mode. A paused session still // prompts (aiAgentSdk.ts gates on !isPaused). // // CONTRACT (enforced by aiGuardrails.readonly.contract.test.ts): every pair // here must also be in TIER2_ACTIONS, and must be a pure read — no state // change on the device, the org, or any external system. When in doubt an // entry does not belong here; leaving a read out only costs one lightweight // prompt. export const TIER2_READONLY_ACTIONS: Record = { manage_policy_feature_link: ['describe'], execute_command: ['event_logs_list', 'file_list', 'list_processes'], file_operations: ['list'], manage_processes: ['list'], manage_scheduled_tasks: ['list'], manage_services: ['list'], system_cleanup: ['list'], }; // #3130 companion for whole tools: base-Tier-2 tools whose EVERY operation is // a read — single-purpose get/list/search tools with no action multiplexing // and no TIER3_ACTIONS escalation (both enforced by the contract test). Same // semantics as TIER2_READONLY_ACTIONS: keep the Tier-2 audit row, skip the // per-step prompt. export const TIER2_READONLY_TOOLS = new Set([ 'get_catalog_item', 'get_contract', 'get_invoice', 'get_quote', 'list_contracts', // Deliverable template sets W05 (#5573): a pure read, like list_contracts. 'list_deliverable_templates', 'list_invoices', 'list_org_documents', 'list_quotes', 'lookup_distributor_product', 'search_catalog', // Reads under an administrator-approved diagnostic grant: the approval // already happened (on the grant), so a covered read runs without a second // per-step prompt; an uncovered one is refused by the handler, never escalated. 'diagnostic_list_directory', 'diagnostic_read_file', ]); /** * Execution plane W04 (spec §5.3). The four sandbox-workspace tools are * Tier 1 — they execute nothing on the fleet — but they are NOT read-only: * they spend compute, write files into a sandbox, and are opt-in per agent. * `isReadOnlyResolution` treats every Tier-1 tool as read-only, which would * (a) make the capability picker list them as "always on" and never write * them to the allowlist, and (b) skip the allowlist gate in * `checkAgentGuardrails`. This set is the ONE exclusion that makes them * allowlist-gated; the carve-out in `checkAgentGuardrails` then keeps them * `allow` (never `propose`/`act`, allowed on device-less runs) once the * allowlist and protected-resource checks pass. Pinned by * aiGuardrails.workspace.contract.test.ts. */ export { WORKSPACE_TOOL_NAMES, type WorkspaceToolName } from './workspace/workspaceToolNames'; export const TIER1_NON_READONLY_TOOLS: ReadonlySet = new Set(WORKSPACE_TOOL_NAMES); // Actions that downgrade to Tier 1 (auto-execute, no approval) even if the tool's base tier is higher // Exported for contract tests only — see the note on TIER2_ACTIONS. export const TIER1_ACTIONS: Record = { security_scan: ['vulnerabilities'], manage_tags: ['list'], // Endpoint messaging history reads (#8742); `send` stays at the tool's base Tier 3. send_endpoint_message: ['list', 'get'], // Full-control W04 (#6758, W04-D2 — LOOSENING, Todd signs off in the PR): // reads of staff-written policy config, already tier 1 on the plural sibling // tools that read the same tables (manage_software_policies list/get). The // writes stay at the base tier 3, classified per action below. manage_software_policy: ['list', 'get'], manage_browser_policy: ['list'], }; /** * Spec 2026-09-23 W01-D3: the explicit read allowlist for registry-tier-1 * multiplexers. A tier-1 tool with an `action`/discriminator enum resolves * to tier 1 (read-only to headless agents, via `isReadOnlyResolution`) for * ANY action not otherwise escalated — so an unclassified new enum member * would silently default to "read" rather than failing closed. Every action * of such a tool must appear here, in TIER2_ACTIONS, or in TIER3_ACTIONS * (enforced by aiGuardrails.tier1WriteActions.contract.test.ts). Exported for * contract tests only — see the note on TIER2_ACTIONS. */ export const TIER1_READ_ACTIONS: Record = { configuration_policy_compliance: ['summary', 'status'], // disk_cleanup:preview reads the latest filesystem-cleanup snapshot and // writes a device_filesystem_cleanup_runs bookkeeping row // (aiToolsFilesystem.ts:391-410) — it does NOT dispatch a scan, and it // changes no device state. Reclassifying it is W04's call (spec W01-D3). disk_cleanup: ['preview'], generate_report: ['list', 'data', 'history', 'download'], // #6268 (base Tier 1): reads over PR A's sweep services; preview writes nothing. ioc_hunt: ['list_rule_sets', 'preview_sweep', 'list_sweeps', 'sweep_status'], get_dns_security: ['allowed', 'blocked', 'redirected'], get_service_monitoring_status: ['status', 'summary', 'results', 'known_services'], manage_alert_rules: ['list_rules', 'get_rule', 'test_rule', 'list_channels', 'alert_summary'], manage_alerts: ['list', 'get'], manage_automations: ['list', 'get', 'history'], manage_backup_configs: ['list', 'get'], manage_backup_profiles: ['list', 'get'], // Full-control W06 (#6776): PSA configuration + ticket-ops reads. Each is a read of // staff-written config or of the caller's own rows; the inbox list and attachment // content are deliberately NOT here (plain tier 2, quorum QW1). manage_ticket_config: ['get_config', 'get_org_settings', 'list_categories', 'list_domains'], manage_ticket_checklist_templates: ['list', 'get'], manage_ticket_attachments: ['list'], manage_ticket_forms: ['list', 'available'], manage_ticket_response_templates: ['list'], manage_time_suggestions: ['list'], // Full-control W07 (#6777): the anomaly list is a read of metric rows (get_alert_correlations and // the extra get_fleet_health actions are whole-tool reads, so they need no entry). manage_device_anomalies: ['list'], // The two whole-tool reads below are multiplexers with an `action` enum, so the tier-1 // write-action contract wants their (all-read) vocabulary declared. `get_fleet_health`'s // default (no action) is the fleet list. get_alert_correlations: ['groups', 'get', 'by_alert', 'links', 'analyze', 'evaluation', 'explain'], get_fleet_health: ['list', 'history', 'offenders', 'evaluation', 'org_summary'], // Full-control W08 (#6778): the topology reads (whole-tool reads declare their vocabulary too). get_topology_graph: ['graph', 'nodes', 'node', 'relationship', 'evidence', 'group_members', 'expansion', 'health'], run_topology_diagnostic: ['collectors', 'get'], manage_topology_monitoring: ['list_policies', 'list_targets', 'get_settings'], manage_topology_templates: ['list_templates', 'list_versions', 'site_options'], apply_topology_template: ['status'], // Full-control W09 (#6779): the SNMP / known-guest / UniFi reads (whole-tool reads declare their vocabulary too). manage_snmp_templates: ['list', 'get', 'browse_oids', 'validate_oids'], get_snmp_metrics: ['history', 'dashboard'], manage_known_guests: ['list'], get_unifi_status: ['status', 'hosts', 'collectors', 'mappings', 'telemetry', 'sync_runs', 'controller_sites'], // Full-control W11 (#6781): the security-policy reads. manage_security_recommendations: ['list'], manage_access_reviews: ['list', 'get'], manage_audit_baselines: ['list', 'compliance', 'device_results'], manage_security_policies: ['list'], manage_recovery_keys: ['list'], manage_pam_rules: ['list_rules', 'get_config', 'list_signer_groups'], // Full-control W12 (#6782): the software reads. manage_software_catalog: ['list', 'get'], manage_software_deployments: ['list', 'get', 'summary', 'results'], manage_software_inventory: ['list', 'names', 'devices'], manage_delivery: ['resolve', 'list_routing', 'list_escalation'], manage_deployments: ['list', 'get', 'device_status'], manage_groups: ['list', 'get', 'preview', 'membership_log'], manage_maintenance_windows: ['list', 'get', 'active_now'], // create/update/delete are retired: they return guidance before any DB // access and map to devices:read in TOOL_PERMISSIONS, so they classify as // reads here. manage_monitors: ['get', 'create', 'update', 'delete'], manage_notification_channels: ['list'], manage_patches: ['list', 'compliance', 'device_history'], manage_peripheral_policies: ['list', 'get'], // manage_processes:list and manage_scheduled_tasks:list are NOT here: they // run a live command on the device, so they sit in TIER2_ACTIONS (+ the // read-only allowlist) with file_operations:list / manage_services:list. manage_saved_filters: ['list', 'get'], manage_service_monitors: ['list'], manage_software_policies: ['list', 'get'], manage_ticket_checklist: ['list', 'list_templates', 'get_template'], manage_tickets: ['list', 'get', 'list_work_types', 'list_links', 'merge_preview'], manage_update_rings: ['list', 'get'], query_agent_versions: ['list_versions', 'check_upgrades'], query_analytics: ['sla_compliance', 'capacity_predictions', 'sla_definitions'], query_backups: ['list_configs', 'list_jobs', 'list_policies'], query_custom_fields: ['list_definitions', 'get_device_values'], // system_cleanup:list is NOT here: it asks the device live (TIER2_ACTIONS). system_cleanup: ['status'], }; // Mutations that require approval (Tier 3) even if the tool is registered as Tier 1 // Exported for contract tests only — see the note on TIER2_ACTIONS. export const TIER3_ACTIONS: Record = { // Wisp W07 (spec §3 "asks first"): every specialist kind the policy does not // auto-admit needs the person's approval in chat — for a bulk reply the card // shows the exact ticket list and message. launch_specialist: [...ASK_FIRST_SPECIALIST_KINDS], // Customer-visible: the text is shown to an end user under the MSP's brand, // so a human must always have read it (same class as a public ticket reply). send_endpoint_message: ['send'], // Privileged-read rule: filesystem READ is privileged. The endpoint agent runs as // root/LocalSystem and does not restrict reads to an approved root, so an // unapproved read can exfiltrate any file (/etc/shadow, SAM hive, SSH keys). // Require interactive approval (Tier 3) for read, same as the mutations. // `list` was deliberately downgraded to Tier 2 (2026-07-20) — recon-only. file_operations: ['read', 'write', 'delete', 'mkdir', 'rename'], manage_services: ['start', 'stop', 'restart'], // Applying a deliverable template set arms unattended ticket creation for // every future period of every applied item — same class as // manage_software_policies create/update (#3552). W05 (#5573). manage_deliverables: ['apply_template'], security_scan: ['quarantine', 'remove', 'restore'], disk_cleanup: ['execute'], // OS-native cleaners (Disk Cleanup v2 §7). `list` is a read-only catalog // probe; `run` executes vetted maintenance binaries (cleanmgr, DISM, // apt-get/dnf, journalctl, tmutil, brew) as root/LocalSystem with a 90-minute // ceiling, and some of its handlers cannot be undone (`Previous // Installations` deletes Windows.old, which is the rollback path). system_cleanup: ['run'], manage_startup_items: ['disable', 'enable'], manage_scheduled_tasks: ['run', 'disable', 'enable'], // Fleet tools — Tier 3 actions (require user approval) manage_configuration_policy: ['create', 'update', 'delete'], manage_deployments: ['create', 'start', 'cancel'], // setup_auto_approval added #3552: it arms unattended patch approval + // reboot policy, the same class as manage_update_rings.autoApprove below. // Its handler is hard-disabled today (aiToolsFleet.ts returns "managed // through configuration policies"), so the gate costs no live workflow — // it is here so re-enabling the handler cannot silently reopen the hole. manage_patches: ['install', 'rollback', 'setup_auto_approval'], // #6268: a fleet IOC sweep (detect-only, single org; scope input-aware in // resolveApprovalScope) and an IOC rule import (publishes a rule set every // policy using it then scans with). ioc_hunt: [IOC_HUNT_RUN_ACTION, 'import_iocs'], manage_groups: ['create', 'update', 'delete'], // create/update/delete added W01-D3 (#6755): handler-disabled today // (aiToolsFleet.ts:2041, managed via configuration policies), same pattern // as manage_patches.setup_auto_approval above — escalated so re-enabling // the handler cannot silently reopen an unallowlisted agent write. manage_automations: ['run', 'create', 'update', 'delete'], manage_processes: ['kill'], manage_policy_feature_link: ['remove'], registry_operations: ['set_value', 'create_key', 'delete_key'], // create/update/delete added W01-D3 (#6755): handler-disabled today // (aiToolsFleet.ts:2742, managed via configuration policies) — escalated so // re-enabling the handler cannot silently reopen an unallowlisted agent // write. manage_maintenance_windows: ['create', 'update', 'delete'], // Policy prerequisite tools (#3552) — the standalone feature policies that // manage_configuration_policy links via featurePolicyId. These were Tier 2 // (auto-execute + audit, no approval) while the configuration policy that // consumes them, and their singular-named siblings that write the SAME // tables, are Tier 3: // // manage_software_policies ~ manage_software_policy (aiToolsCompliance.ts, base tier 3) // manage_peripheral_policies ~ manage_peripheral_policy (aiToolsPeripherals.ts, base tier 3) // manage_update_rings ~ manage_patches:install (Tier 3 above) // // The tier gap was the reachable one: the prereq tools are the ones listed // in aiAgentSdkTools' TOOL_TIERS, so they — not the Tier-3 singulars — are // what chat/MCP actually calls. Each create/update payload ARMS unattended // action on real endpoints with no human in the loop: // - software policies: `enforceMode` + `remediationOptions.autoUninstall` // turn a detect-only allowlist into fleet-wide auto-uninstall (the #3381 // mass-uninstall failure mode). `remediationOptions.autoInstall` (#5505 // desired-state install) is NOT gated the same way as the fields above — // it is never accepted from the AI at all. The four handler write sites // in aiToolsCompliance.ts/aiToolsPolicyPrereqs.ts refuse an // autoInstall:true outright, regardless of tier or approval, because // only a human operator holding devices.execute + MFA may arm software // installation (contract-A D4). Tier-3 approval on this tool remains // for enforceMode/autoUninstall; it is not the mechanism that protects // autoInstall. // - update rings: `autoApprove` + `deadlineDays` + `gracePeriodHours` arm // unattended patch installs with FORCED reboots — the standing-rule form // of manage_patches:install, which already requires approval. // - peripheral policies: `action_type: block | read_only` cuts off USB / // Bluetooth / Thunderbolt access across the target scope. // // `list`/`get` are deliberately NOT listed: the tools keep base tier 1, so // reads still auto-execute with no prompt. Only the mutations escalate. // // Deliberately NOT escalated (judgment calls, left at Tier 2, #3552): // - manage_backup_configs (and, until W03 #6757, manage_backup_profiles — // see its entry below). They change storage // destinations, credentials, retention and selection sets — real, but // PROTECTIVE scheduling rather than enforcement or remediation: no // payload there causes an agent to uninstall, block, or reboot anything. // Escalating them is defensible under a broader "any live linked policy // change needs approval" rule; that is a separate product decision. manage_update_rings: ['create', 'update'], manage_software_policies: ['create', 'update'], manage_peripheral_policies: ['create', 'update'], // Backup & DR — Tier 3 actions (require user approval) manage_dr_plan: ['delete_group'], // W03 (#6757, W03-D2): create queues a HYPERV_CHECKPOINT command on the // customer's Hyper-V host — "touches a customer machine" is supervised at // minimum. delete/apply destroy or rewind state (four-eyes below). manage_hyperv_checkpoints: ['create', 'delete', 'apply'], // W03-D1: whether a profile is partner-wide is DB state (orgId null), and // guardrails are DB-free. Narrowing a linked partner-wide profile silently // stops protecting data in every org, so update/delete fail safe. manage_backup_profiles: ['update', 'delete'], // W03-D2: both actions push vault_configure to the device, and vaultPath // may point backup copies at a model-chosen SMB share — the W02 // notification-channel class (data to a new destination). configure_vault: ['create', 'update'], // Monitoring tools — Tier 3 actions (require user approval) manage_delivery: ['create_routing','update_routing','delete_routing','set_default','create_escalation','update_escalation','delete_escalation'], // Ticketing — move_org is a tenant-shape mutation and requires approval. // log_time_entry/start_timer/stop_timer downgraded to Tier 2 (2026-07-20). // edit_comment/delete_comment (W02, #6756): no restore — a public comment's // text changes or disappears in the portal at once (the old text survives // only in the audit log). manage_tickets: ['move_org', 'merge', 'edit_comment', 'delete_comment'], // #8883 W5: ticket routing rule writes (partner-wide gate enforced in the service). manage_ticket_routing_rules: ['create', 'update', 'reorder', 'delete'], // Full-control W06 (#6776). Partner-wide ticket configuration, MFA-route mutations and // terminal/irreversible acts are tier 3 (plan "Per-tool class review"); the four hard // deletes are four-eyes (irreversible AND partner-wide, spec Class rule). Splits by // scope are in TIER3_SUPERVISED_ACTIONS / TIER3_FOUR_EYES_ACTIONS below. manage_ticket_config: [ 'update_org_settings', 'create_category', 'update_category', 'delete_category', 'reorder_categories', 'create_status', 'update_status', 'reorder_statuses', 'set_priorities', 'create_domain', 'update_domain', 'delete_domain', ], manage_ticket_checklist_templates: ['update', 'delete', 'add_item', 'update_item', 'remove_item', 'reorder_items'], manage_ticket_inbox: ['convert', 'dismiss'], manage_ticket_attachments: ['delete'], manage_ticket_forms: ['create', 'update', 'delete'], manage_ticket_response_templates: ['create', 'update', 'delete'], // Full-control W07 (#6777): promoting an anomaly raises a real alert and publishes alert.triggered, // which reaches notification channels and ticket automation (spec Q2: reaches a person). manage_device_anomalies: ['promote'], // Full-control W08 (#6778): see TIER3_SUPERVISED_ACTIONS. manage_topology_templates: ['publish_version', 'revoke_template'], apply_topology_template: ['apply'], // Full-control W11 (#6781): see TIER3_SUPERVISED_ACTIONS; manage_security_policies:create is input-aware. manage_access_reviews: ['create'], manage_audit_baselines: ['create'], manage_security_policies: ['create', 'update'], manage_recovery_keys: ['rotate', 'collect'], manage_pam_rules: [ 'create_rule', 'update_rule', 'delete_rule', 'set_config', 'create_signer_group', 'update_signer_group', 'delete_signer_group', ], // Full-control W12 (#6782): see TIER3_SUPERVISED_ACTIONS. manage_software_catalog: [ 'archive', 'add_version', 'update_version', 'promote_version', 'add_install_method', 'update_install_method', 'delete_install_method', ], manage_software_deployments: ['create', 'retry'], manage_device_software: ['update', 'uninstall'], manage_software_inventory: ['approve', 'deny', 'clear'], manage_custom_fields: ['delete_definition', 'set_values'], // Money-authoring drafts vs money-moving actions (#2551): this boundary // tracks reversibility/external-commitment, not dollar amount. Drafting — // create_draft, add_manual_line, add_catalog_line, update, etc. — only // mutates an internal record nobody outside Breeze has seen yet; it's // Tier 2 (auto-execute + audit) no matter how large the draft's total is. // The step that actually commits externally — issuing/voiding an invoice, // recording/voiding a payment, sending a quote to the customer, or // transitioning a contract's lifecycle — is what exposes the change beyond // Breeze, so THAT step is Tier 3 regardless of amount. A five-figure quote // draft and a $10 one get the same tier; only `send` escalates. // // This is deliberate, not an oversight that `manage_organizations: // create_org` is Tier 3 while `manage_quotes: create_draft` is Tier 2 — // create_org is gated on tenant-structure mutation, an unrelated axis, not // financial size. A partner-configurable dollar-amount escalation // threshold for drafting was proposed and explicitly deferred (not // rejected) — see #2551 for the full analysis and the decision record. manage_invoices: ['issue', 'void', 'record_payment', 'void_payment'], manage_contracts: ['activate', 'pause', 'resume', 'cancel'], // decline (W02, #6756) records the CUSTOMER's decision on their behalf with // no reopen (only revise), and its route requires quotes:accept; // create_pay_link opens a Stripe Checkout session on the partner's // connected account for the converted invoice. manage_quotes: ['send', 'decline', 'create_pay_link'], // Billing price book (W02, #6756). Contract renewals read the price book // LIVE (generateDueInvoice → addContractLine → resolvePrice in // catalogService.ts), so every price-affecting edit reaches future // invoices. Supervised, not four-eyes, because each handler returns the // value it replaced (`previous`), making it reversible within a minute. // create_item stays tier 2: a new, unreferenced item changes no document. manage_billing_catalog: ['update_item', 'archive_item', 'set_price', 'remove_price', 'set_org_price', 'remove_org_price', 'set_bundle_components'], // Notification channels (W02, #6756). update redirects live, already-routed // alert data to a new destination; delete silences every linked route and // destroys the stored destination; create points at an unverified owner. manage_notification_channels: ['create', 'update', 'delete'], // Org lifecycle (issue #2366) — tenant-shape mutations require approval. // add_contact (#3258) writes customer PII (a first-class contact record), // so it escalates too, even though it reshapes no tenant boundary. // update_org's approval SCOPE (not its tier) is input-aware — see // resolveApprovalScope's override hook below. manage_organizations: ['create_org', 'update_org', 'create_site', 'add_contact'], // s1_threat_action is registered at base Tier 3 (see TIER3_FOUR_EYES_TOOLS / // TIER3_SUPERVISED_TOOLS below for its whole-tool catch-all), but its // `action` enum (kill/quarantine/rollback) is a real dispatch discriminator // — same shape as manage_services/security_scan above — so it is split here // too: rollback (containment RELEASE) is four_eyes, kill/quarantine // (containment/mitigation) are supervised. See spec §3.2. s1_threat_action: ['kill', 'quarantine', 'rollback'], // EDR response actions (#8167 W03, spec §4.10). Same split as // s1_threat_action: isolate is protective containment (supervised), // releasing isolation reverses a mitigation (four_eyes). Listed by hand — // the tier-table contracts parse this file as text. edr_endpoint_action: ['isolate', 'unisolate'], }; // Spec 2026-08-05 §3: within tier 3, `four_eyes` requires a SECOND human // (approvals:decide holder other than the requester); everything else is // `supervised` — the requesting human approves their own AI action with a // plain click, gated on their existing RBAC. // // THE FAIL-SAFE IS PER-TOOL, NOT PER-ACTION. resolveApprovalScope's final // `return 'four_eyes'` is only reached by a tool that is in NEITHER whole-tool // set — an unknown/extension tool. A tool that IS in TIER3_SUPERVISED_TOOLS // short-circuits on the whole-tool lookup, so an action of that tool which is // listed in no *_ACTIONS table falls through to `supervised` — the WEAKER // scope, not the fail-safe. Tools in the supervised whole-tool set must // therefore enumerate every action they accept; the contract test // ("action-multiplexed tools in a whole-tool scope set enumerate every // action") enforces that against the tools' real action enums, so a new enum // member fails CI instead of silently self-approving. // // Three classification mechanisms, together covering the full tier-3 surface: // 1. Per-action pairs drawn from TIER3_ACTIONS above (TIER3_*_ACTIONS). // 2. Whole registered tools whose BASE tier is 3 (TIER3_*_TOOLS) — this // covers pure whole-tool surfaces (execute_command) AND the catch-all // for action-multiplexed base-Tier-3 tools whose action falls outside // every TIER1/2/3_ACTIONS table (e.g. security_scan 'scan'/'status', // which are not itself in TIER3_ACTIONS). A tool can legitimately // appear in both an *_ACTIONS table and the complementary whole-tool // set (manage_services, security_scan, s1_threat_action). // 3. Input-aware overrides in resolveApprovalScope, for tool/action pairs // whose scope depends on ARGUMENT CONTENT, not just the tool/action // name — manage_organizations:update_org (status present vs a plain // rename) and s1_isolate_device (boolean `isolate`, not an `action` // string, so it can't even be an action-classified pair). These are // deliberately NOT listed in the static tables above; they are // exempted from the "classified in exactly one static table" contract // test via TIER3_INPUT_AWARE_ACTIONS / TIER3_INPUT_AWARE_TOOLS below // and instead get dedicated both-branches tests. // // See docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md §3.2 // for the full classification rationale. export const TIER3_FOUR_EYES_ACTIONS: Record = { // Financial / externally binding. `void` is not named in spec §3.2's // bullet list, but TOOL_PERMISSIONS maps it to the same `invoices:send` // RBAC action as issue/record_payment/void_payment — voiding an issued // invoice is the same externally-binding class, so it is classified // alongside them (see task report "concerns"). manage_invoices: ['issue', 'void', 'record_payment', 'void_payment'], manage_contracts: ['activate', 'cancel'], manage_quotes: ['send'], // update_org is deliberately ABSENT here: its scope is input-aware (a // `status` change is four_eyes, a plain rename is supervised) and is // resolved by resolveApprovalScope's override hook, not this static table. // See TIER3_INPUT_AWARE_ACTIONS. manage_organizations: ['create_org'], // #8972: `merge` is Tier 3 (one approver, spec §5.7), not four-eyes: it is // same-org, unlike move_org's re-tenanting. manage_tickets: ['move_org'], // Full-control W06 (#6776, W06-D10): hard deletes of config that is partner-wide, or whose // ownership is DB state (guardrails are DB-free): irreversible AND partner-wide. manage_ticket_config: ['delete_domain'], manage_ticket_checklist_templates: ['delete'], manage_ticket_forms: ['delete'], manage_ticket_response_templates: ['delete'], // W02 QV3 (#6756): deleting a PARTNER-WIDE channel is irreversible (its // destination and secret config are gone) and silences alerting for every // org. Guardrails are DB-free and cannot tell a partner-wide channel from an // org one by id, so the fail-safe applies to every delete. manage_notification_channels: ['delete'], // Grants an AUTHORITY, not a device action: it converts "this agent must ask // a human for " into "this agent may run unattended for this // org from now on". Proposing that and authorising it are separate // responsibilities. See services/aiToolsAiAgentGovernance.ts. manage_ai_agents: ['authorize_supervised_key'], // Destroys or rewinds state. manage_hyperv_checkpoints: ['delete', 'apply'], manage_patches: ['rollback'], // Containment RELEASE: threat rollback reverses a prior mitigation. kill/ // quarantine are protective mitigation and stay supervised (same rationale // as s1_isolate_device isolate — urgent protective action must not wait). s1_threat_action: ['rollback'], // Containment RELEASE, same rationale as s1_threat_action rollback (#8167). edr_endpoint_action: ['unisolate'], }; export const TIER3_FOUR_EYES_TOOLS = new Set([ // Restore / DR execution — "destroys or rewinds state": these overwrite or // replace live state from a prior snapshot. 'restore_snapshot', 'restore_as_vm', 'instant_boot_vm', 'restore_mssql_database', 'restore_hyperv_vm', 'restore_c2c_items', 'execute_dr_plan', // Surveillance-grade / unattended access. 'computer_control', 'create_remote_session', // Tenant destruction. 'delete_tenant', // AI agent authority grants (P2-5, #4192). Whole-tool member on top of the // TIER3_FOUR_EYES_ACTIONS entry above, so a future action of this tool // defaults to four_eyes instead of falling through to `supervised`. 'manage_ai_agents', // Identity / account control — M365 (helpdesk tools; dispatch outside the // headless registry via makeSessionAwareHandler, but still carry a real // tier via m365ToolTiers). 'm365_disable_user', 'm365_reset_password', // Identity / account control — M365 control-plane identity catalog (Operator // Recipe Library M1, spec §7.1): all Tier 3, all four-eyes. 'm365_revoke_sessions', 'm365_remove_license', 'm365_assign_license', 'm365_remove_from_group', 'm365_add_to_group', 'm365_retire_intune_device', 'm365_create_user', 'm365_set_auto_reply', // AI Operator plan approval — ONE four-eyes decision over an ordered, // digest-pinned set of provider effects (Recipe Library spec §6.4). Four-eyes // for the same reason every effect inside it is: the set can destroy a // person's access across two providers. 'operator_plan', // Wisp memory (W03 #9909): human-only (HUMAN_ONLY_TOOLS) and never declared to // chat, Helper or agents — a person confirms/retracts on the memory page. Tier 3 // with operator_plan so the external MCP never advertises them; four_eyes is // the conservative classification should any approval path ever reach them. 'confirm_memory', 'retract_memory', // Identity / account control — Google Workspace. Every mutating Google tool // acts on a human identity/mailbox/account, not a device, so the whole // mutating surface is classified four_eyes (categorical reading of spec // §3.2's "these act on human identities, not devices"; only a subset of // these — password/2SV reset, forwarding/delegates, offboarding/disable, // device wipe — is named explicitly in the design doc. See task report // "concerns" for the borderline members: restore_user, signout, // set_vacation, update_user, share_calendar, move_ou, rename_user, // add/remove_from_group, assign/remove_license). 'google_reset_password', 'google_reset_2sv', 'google_set_forwarding', 'google_disable_forwarding', 'google_add_mail_delegate', 'google_remove_mail_delegate', 'google_suspend_user', 'google_offboard_user', 'google_wipe_mobile_device', 'google_account_wipe_mobile_device', 'google_restore_user', 'google_signout', 'google_set_vacation', 'google_update_user', 'google_share_calendar', 'google_move_ou', 'google_rename_user', 'google_add_to_group', 'google_remove_from_group', 'google_assign_license', 'google_remove_license', // s1_threat_action whole-tool catch-all: every enum value is covered by // TIER3_FOUR_EYES_ACTIONS/TIER3_SUPERVISED_ACTIONS above, so this only // matters if the action is missing/unrecognized — fail-safe. 's1_threat_action', // EDR response actions whole-tool catch-all (#8167): an unknown action // string fails closed to four_eyes instead of falling through. 'edr_endpoint_action', // PAM elevation request. Classified four_eyes out of caution (not named in // spec §3.2). Full-control W04 (#6758) re-read the handler: it always // inserts a PENDING request and applies only an auto_deny rule verdict — // an auto_approve rule is NOT applied (aiToolsPam.ts), so a separate // pam:approve holder still answers it. Four-eyes is kept, not loosened. 'request_elevation', ]); // AGENT_HUMAN_ONLY_TOOLS and AGENT_DENIED_READ_TOOLS moved to // aiToolExposure.ts (W01 quorum amendment WQ4, #6755) and are re-exported // near the top of this file so every existing importer keeps working. export const TIER3_SUPERVISED_ACTIONS: Record = { // Wisp W07: one approver (the person asking, or a colleague) — the // specialist's own effects still mint their own intents. launch_specialist: [...ASK_FIRST_SPECIALIST_KINDS], // #6268 Gate B 8: an IOC import authors detection rules (a set with // auto-quarantine on is refused in the handler, under the set lock). // ioc_hunt:sweep is NOT here: its scope is input-aware (expectedDeviceCount). ioc_hunt: ['import_iocs'], // Supervised, not four_eyes: same class as a public ticket reply. list/get // resolve to Tier 1 (TIER1_ACTIONS) and never reach tier 3. send_endpoint_message: ['send'], // #8972: merging two tickets of ONE org (spec §5.7: Tier 3, approval). It is // irreversible, which is why it needs approval at all; it is not tenant- // shaping like move_org (four_eyes), so one approver suffices. // #8883 W5: routing rules are config objects, not externally binding: one approver. manage_ticket_routing_rules: ['create', 'update', 'reorder', 'delete'], // complement of TIER3_FOUR_EYES_ACTIONS within TIER3_ACTIONS. file_operations: ['read', 'write', 'delete', 'mkdir', 'rename'], manage_services: ['start', 'stop', 'restart'], // `scan`/`status` are NOT in TIER3_ACTIONS — they need no tier escalation, // security_scan's BASE tier is already 3. They are listed here anyway // because security_scan is also in TIER3_SUPERVISED_TOOLS: without an // explicit entry they would reach `supervised` via the whole-tool // short-circuit rather than by a decision, which is exactly the fall-through // the enumeration contract test exists to forbid. (`vulnerabilities` is // classified in TIER1_ACTIONS and never reaches tier 3 at all.) security_scan: ['quarantine', 'remove', 'restore', 'scan', 'status'], disk_cleanup: ['execute'], // Supervised, not four_eyes: the actions are a closed, vetted catalog of // maintenance operations a tech could run by hand on the box, and nothing in // it is externally binding or financial. Its irreversibility (Windows.old) // is a property of the ACTION the tech picked, not of the approval class. system_cleanup: ['run'], manage_startup_items: ['disable', 'enable'], manage_scheduled_tasks: ['run', 'disable', 'enable'], manage_configuration_policy: ['create', 'update', 'delete'], // W05 (#5573). `supervised`, not four_eyes: applying a template set creates // ordinary org config (a recurring obligation schedule) that a tech can // deactivate afterwards. Nothing here is externally binding, financial, or // state-destroying — the four_eyes classes above. manage_deliverables: ['apply_template'], manage_deployments: ['create', 'start', 'cancel'], manage_patches: ['install', 'setup_auto_approval'], manage_groups: ['create', 'update', 'delete'], // create/update/delete added W01-D3 (#6755): handler-disabled today, same // supervised (not four_eyes) class as the sibling fleet tools above — // nothing here is externally binding or financial. manage_automations: ['run', 'create', 'update', 'delete'], manage_processes: ['kill'], manage_policy_feature_link: ['remove'], registry_operations: ['set_value', 'create_key', 'delete_key'], // create/update/delete added W01-D3 (#6755): handler-disabled today, // supervised (not four_eyes) — nothing here is externally binding or // financial. manage_maintenance_windows: ['create', 'update', 'delete'], // #3552 policy-prerequisite escalations. `supervised`, matching the // configuration policy they link into (manage_configuration_policy // create/update/delete above), the Tier-3 singular siblings in // TIER3_SUPERVISED_TOOLS below, and manage_patches:install. Spec §3.2 // reserves four_eyes for externally-binding, identity, and // destroy/rewind actions; arming a policy is none of those, and // blast-radius-based escalation is explicitly deferred in the design doc. manage_update_rings: ['create', 'update'], manage_software_policies: ['create', 'update'], manage_peripheral_policies: ['create', 'update'], manage_dr_plan: ['delete_group'], manage_delivery: ['create_routing','update_routing','delete_routing','set_default','create_escalation','update_escalation','delete_escalation'], manage_contracts: ['pause', 'resume'], // Full-control W04 (#6758): base-tier-3 policy and remediation tools whose // whole-tool TIER3_SUPERVISED_TOOLS entry was the only classifier. Every // write is enumerated so a new enum member reds the enumeration contract // instead of landing on the backstop. Software update can arm fleet // uninstall; peripheral writes queue device syncs; secure_delete is // irreversible but single-device and tenant-internal (sup by the class // rule); the three finding status flips have no reopen path (kept sup). manage_software_policy: ['create', 'update', 'delete'], manage_browser_policy: ['create', 'update', 'apply'], manage_peripheral_policy: ['create', 'update', 'disable', 'add_exception', 'remove_exception'], remediate_sensitive_data: ['encrypt', 'quarantine', 'secure_delete', 'accept_risk', 'false_positive', 'mark_remediated'], // W03 (#6757) — see the TIER3_ACTIONS notes for each. manage_hyperv_vm is // registry tier 3 (whole-tool backstop below); every power action is // enumerated here so a new one reds the enumeration contract. force_stop is // irreversible but single-device and tenant-internal: supervised, not // four-eyes, under the spec's class rule. manage_hyperv_vm: ['start', 'stop', 'force_stop', 'pause', 'resume', 'save'], manage_hyperv_checkpoints: ['create'], manage_backup_profiles: ['update', 'delete'], configure_vault: ['create', 'update'], // W02 (#6756) — see the TIER3_ACTIONS notes for each. manage_tickets: ['edit_comment', 'delete_comment', 'merge'], // Full-control W06 (#6776): reversible partner-wide config, MFA-route mutations, // customer-visible forms, terminal inbox acts and the permanent attachment delete. manage_ticket_config: [ 'update_org_settings', 'create_category', 'update_category', 'delete_category', 'reorder_categories', 'create_status', 'update_status', 'reorder_statuses', 'set_priorities', 'create_domain', 'update_domain', ], manage_ticket_checklist_templates: ['update', 'add_item', 'update_item', 'remove_item', 'reorder_items'], manage_ticket_inbox: ['convert', 'dismiss'], manage_ticket_attachments: ['delete'], manage_ticket_forms: ['create', 'update'], // Full-control W07 (#6777): see TIER3_ACTIONS. manage_device_anomalies: ['promote'], // Full-control W08 (#6778): publishing makes a version appliable partner/org-wide and is not undoable; // revoking a template is terminal and disables every policy that references it; applying rewrites the // monitoring configuration of every previewed site. One approver (the requester). manage_topology_templates: ['publish_version', 'revoke_template'], apply_topology_template: ['apply'], // Full-control W11 (#6781, plan class review + quorum). One approver (the requester): // - access-review create has no delete or cancel (QW6); audit-baseline create arms the daily // collect_audit_policy sweep on the org's devices of that OS (QW3); // - a security-policy update edits AV/EDR settings (no endpoint reads them today, plan fact 5; kept // supervised); create is input-aware (partner-wide is four-eyes, resolveApprovalScope); // - recovery-key rotate invalidates the current key (irreversible, one device); collect re-escrows keys; // - PAM writes decide who gets local admin. They cannot write auto_approve, and in an org with an // auto-approve rule only non-widening changes are allowed (W11-D3), so none grants standing elevation. manage_access_reviews: ['create'], manage_audit_baselines: ['create'], manage_security_policies: ['update'], manage_recovery_keys: ['rotate', 'collect'], manage_pam_rules: [ 'create_rule', 'update_rule', 'delete_rule', 'set_config', 'create_signer_group', 'update_signer_group', 'delete_signer_group', ], // Full-control W12 (#6782, plan class review). One approver (the requester): // - catalog install-target edits (a version added — auto-promoted —, edited or promoted; an install method added, // edited or deleted) change what armed auto-install policies, automations and `latest` deployments install on // customer machines with no further approval (plan fact 1); archive has no restore route; // - deployment create/retry dispatch software_install; device-software update/uninstall queue a device command; // - inventory approve/deny/clear append to (or remove from) the org's default allow/blocklist, which with an // enforcing policy drives auto-uninstall (plan fact 5); // - a custom-field definition delete cascades every device's value (irreversible); a value write changes installer // arguments and dynamic-group targeting (quorum QW1; set_values returns the previous values). manage_software_catalog: [ 'archive', 'add_version', 'update_version', 'promote_version', 'add_install_method', 'update_install_method', 'delete_install_method', ], manage_software_deployments: ['create', 'retry'], manage_device_software: ['update', 'uninstall'], manage_software_inventory: ['approve', 'deny', 'clear'], manage_custom_fields: ['delete_definition', 'set_values'], manage_ticket_response_templates: ['create', 'update'], manage_quotes: ['decline', 'create_pay_link'], manage_billing_catalog: ['update_item', 'archive_item', 'set_price', 'remove_price', 'set_org_price', 'remove_org_price', 'set_bundle_components'], manage_notification_channels: ['create', 'update'], // create_site adds a location within an existing org, not a new tenant — // spec §3.2's tenant-shape bullet names only create_org/update_org. // add_contact (#3258) writes customer PII but is neither externally-binding // nor identity/destroy-class, so it stays supervised alongside create_site // rather than four_eyes — see spec §5. manage_organizations: ['create_site', 'add_contact'], s1_threat_action: ['kill', 'quarantine'], // Protective containment must not wait on a second approver (#8167). edr_endpoint_action: ['isolate'], }; export const TIER3_SUPERVISED_TOOLS = new Set([ // Tool Catalog W04 (#5219, Decision 11): starting a flow carries the upper // bound of any flow's tier until W05's per-flow tiers; one requester tap. 'run_flow', // Wisp W07: the specialist approval carrier — one approver, no four-eyes and // no effect-digest pin (the coordinator re-runs every gate at release under // the approver). launch_specialist is NOT here: its base tier is 2, and its // tier-3 kinds are classified by TIER3_SUPERVISED_ACTIONS above. 'wisp_specialist_request', // #8742: customer-visible endpoint message; every action is enumerated in // TIER1_ACTIONS / TIER3_SUPERVISED_ACTIONS. 'send_endpoint_message', // The customer's "regular work on a PC" (spec §3.2's explicit supervised list). 'execute_command', 'run_script', // #3525: stopping a script is a de-escalation — it never starts work, carries // no operator-chosen content, target, credential or binary, and the worst // outcome of an unwanted one is a job that has to be re-run. Supervised, at // the same gate as the run_script it undoes; four_eyes would leave a runaway // script on a customer endpoint while a second approver is found. 'cancel_script_execution', // s1_isolate_device is deliberately ABSENT here: its boolean `isolate` // discriminator cannot be action-classified (spec §3.1), so its scope is // resolved by resolveApprovalScope's override hook instead of this static // set. See TIER3_INPUT_AWARE_TOOLS. // Whole-tool BACKSTOP complementing their _ACTIONS entries above — NOT the // effective classifier. Every action these three accept is enumerated in a // TIER1/TIER2/TIER3_*_ACTIONS table, enforced by the enumeration contract // test; membership here only matters for a missing/unrecognized action. // Reaching `supervised` here for a REAL action means someone added an enum // member without classifying it, and the contract test fails. 'manage_services', 'security_scan', 'manage_startup_items', 'take_screenshot', 'analyze_screen', 'apply_cis_remediation', 'manage_hyperv_vm', 'manage_peripheral_policy', 'manage_software_policy', 'manage_browser_policy', // Full-control W12 (#6782): bootstrap_homebrew has no action (whole tool); manage_device_software's two actions are // enumerated in TIER3_SUPERVISED_ACTIONS — this is the backstop. 'bootstrap_homebrew', 'manage_device_software', // Monitor definitions (#5289 Task 8): ordinary config-object CRUD (create/ // update/delete/enable/disable/attach/detach), same class as the software/ // browser/peripheral policy tools above — no identity, tenant-destruction, // or restore/rewind action in its surface. 'manage_monitor_definitions', // Ticket routing rules (#8883 W5): config-object CRUD + reorder, same class; // whole-tool backstop for its TIER3_SUPERVISED_ACTIONS entry. 'manage_ticket_routing_rules', 'network_discovery', 'remediate_sensitive_data', 'remediate_software_violation', 'remediate_vulnerability', 'execute_playbook', 'execute_containment', // Backup triggers / agent maintenance — spec §3.2's explicit supervised list // ("backup triggers, ... agent upgrades"). 'trigger_backup', 'trigger_hyperv_backup', 'trigger_mssql_backup', 'trigger_agent_upgrade', 'trigger_agent_restart', // Full-control W04 (#6758, W04-D7): registry tier 2 → 3. Creating a // baseline arms a RECURRING on-site discovery scan of a model-chosen subnet // (every 1-168 h) — the one-shot network_discovery is already supervised, // and at tier 2 auto_approve would schedule it with no prompt. 'configure_network_baseline', // W03 (#6757): registry tier 2 → 3. Vault sync and MSSQL verify queue a // device command (VAULT_SYNC / MSSQL_VERIFY stages the backup artifact on // the device); C2C sync is a vendor pull by intent (today the worker fails // it not-implemented). Same class as the backup triggers above. 'trigger_vault_sync', 'trigger_c2c_sync', 'verify_mssql_backup', // Topology M4 Task 4 (#6000), same class as network_discovery: every // accepted recipe is ONE bounded, same-site OBSERVATIONAL check from the // fixed M1/M3 set (gateway/DNS/internet/configured target/routed trace) — // no arbitrary shell, configuration, identity, destination or destructive // operation exists in its input. Supervised removes nothing: it still needs // an explicit approval with a FRESH second factor (freshApproverFactor.ts), // a mandatory pinned effect digest and live re-authorization at release. It // is not policy-decidable and has no unattended grant. 'diagnose_connectivity', // Real outbound POST to a human-configured // URL, and its route requires MFA — see the registry-tier comment beside // aiToolsIntegrations.ts's test_webhook registration. 'test_webhook', ]); /** * Tier-3 (tool, action) pairs whose approval scope is resolved from INPUT * content by resolveApprovalScope's override hooks, not a static lookup in * TIER3_FOUR_EYES_ACTIONS / TIER3_SUPERVISED_ACTIONS. Exists purely so * aiGuardrails.approvalScope.contract.test.ts can exempt these pairs from the * "classified in exactly one static table" invariant — each one instead has * its own dedicated both-branches test. */ export const TIER3_INPUT_AWARE_ACTIONS: ReadonlySet = new Set([ 'manage_organizations:update_org', // #6268 Gate B 6: supervised up to IOC_HUNT_FOUR_EYES_DEVICE_THRESHOLD // expected devices, four_eyes above it or when the count is missing. 'ioc_hunt:sweep', // A customer-visible ticket reply is a different class of act from an // internal note — the note stays at the tool's base Tier 2 (auto-execute // + audit), but an AI-composed comment that will be shown to the ticket // requester needs a human to have actually looked at it first. Only the // INPUT (`isPublic`) says which one a given call is, so it cannot be // classified by (tool, action) alone in the static tables. 'manage_tickets:comment', // W02 (#6756): resolving a ticket emails the requester (with the supplied // resolution note), and a custom statusName resolves to a base status only // through the DB, so it fails safe. Repointing the requester redirects every // later requester email to an address the model chose. 'manage_tickets:update_status', 'manage_tickets:update_fields', // RMM-QA-176 D9: a 'maintenance' feature link is the canonical // monitoring-suppression source, so authoring one is a different class of // act from authoring any other link — but only the INPUT says which it is, // so it cannot be classified by (tool, action) in the static tables. // #5511 W02: same reasoning for a warranty link that switches on device-side // HP CMSL collection — the pair is unchanged, the predicate gained an arm. 'manage_policy_feature_link:add', 'manage_policy_feature_link:update', // W03 (#6757): a partner-wide backup profile ("all orgs") is supervised; // an org-owned one stays at the base Tier 2. Only the create INPUT says // which — on update/delete ownership is DB state (static supervised). 'manage_backup_profiles:create', // W06 (#6776): a partner-wide checklist template is supervised; an org-owned one stays at // the base tier 2. Only the create INPUT (`data.ownerScope`) says which. 'manage_ticket_checklist_templates:create', // Full-control W12 (#6782, quorum QW1): a partner-wide catalog item or custom-field definition (`ownerScope: // 'partner'`) is supervised; an org-owned one stays at the base tier 2. 'manage_software_catalog:create', 'manage_custom_fields:create_definition', // Full-control W11 (#6781, W11-D4): deactivation is irreversible in the product; an org row is supervised, a // partner-wide row (every org) four-eyes. Ownership is DB state, so the call ASSERTS it in `ownerScope` and // the handler refuses a mismatch; only the literal 'organization' resolves supervised. 'manage_compliance_policies:deactivate', // Full-control W11 (#6781, W11-D13): a security policy has no delete; an org create is supervised, a // partner-wide create (`ownerScope: 'partner'`, every org) four-eyes. Only the create INPUT says which. 'manage_security_policies:create', ]); /** * True when a (tool, action, input) triple escalates to Tier 3 on argument * CONTENT. Exported so checkGuardrails, resolveApprovalScope and the tests all * ask the SAME question — a second copy of this predicate is how a tier and * its scope drift apart. * * manage_tickets (W02, #6756): a public comment, a resolve (or any custom * statusName) and an update_fields that repoints the requester — each reaches * the ticket requester by email. Internal notes and other status/field edits * stay at the base tier. * * Two arms on manage_policy_feature_link's add/update: * * - `featureType === 'maintenance'` (RMM-QA-176 D9). Strict `===`: a * non-string featureType stays at the base tier, which is safe here because * the handler writes exactly the featureType it was given, so a value that * is not the literal 'maintenance' cannot create a maintenance link either. * The handler's own principal check (D9.3) is the belt to this brace for * `update`, where featureType is not a required input. * * - an inlineSettings payload that would leave HP CMSL warranty collection * ON (#5511 W02, contract D4). Enabling it installs HP's CMSL module on * every HP endpoint the policy reaches, which is a software deployment — * gated behind devices.execute + MFA on the HTTP routes, and this tool * reaches addFeatureLink without passing through any of them. Keyed on the * SETTINGS CONTENT rather than on featureType precisely because featureType * is not a required input on `update`, which is the call that turns * collection on for an existing link. A warranty link carrying only alert * thresholds installs nothing and deliberately stays at the base tier. * * The action guard is not decoration: without it a read (`list`) carrying a * stray featureType or inlineSettings argument would be escalated into an * approval that the MCP transport then denies outright. */ /** `manage_tickets update_fields` keys that change who receives requester email (W02, #6756). */ const TICKET_REQUESTER_FIELD_KEYS = ['submittedBy', 'submitterName', 'submitterEmail'] as const; export function isInputAwareTier3( toolName: string, action: string | undefined, input: Record, ): boolean { if (toolName === 'manage_tickets') { if (action === 'comment') { // Mirrors the handler's own default (aiToolsTicketing.ts): only an // EXPLICIT `isPublic: true` requests a customer-visible reply. Absent, // `false` or a non-boolean stays an internal note at the base Tier 2 — // the handler writes `isPublic: input.isPublic === true`, the same // expression, so the guard and the write cannot disagree. return input.isPublic === true; } if (action === 'update_status') { // `to === 'resolved'` emails the requester (ticketNotifyWorker.ts // ticket.status_changed). A custom `statusName` is resolved to a base // status only in the DB; guardrails are DB-free, so it escalates. return input.status === 'resolved' || input.statusName !== undefined; } if (action === 'update_fields') { // Repointing the requester redirects every later public comment and // resolve email to a model-chosen address. const fields = input.fields; return typeof fields === 'object' && fields !== null && TICKET_REQUESTER_FIELD_KEYS.some((key) => Object.hasOwn(fields, key)); } return false; } if (toolName === 'collect_evidence') { // W04-D3 (#6758): a screenshot is screen capture (take_screenshot is tier // 3 supervised). A non-array evidenceTypes cannot be read, so it fails // safe; Zod rejects it a moment later anyway. const types = input.evidenceTypes; return !Array.isArray(types) || types.includes('screenshot'); } if (toolName === 'manage_ticket_checklist_templates') { // Mirrors the handler: createChecklistTemplateSchema defaults ownerScope to 'organization', so only the // literal 'partner' makes a partner-wide row. A `data` that is not an object is rejected by the handler's // schema before any write, so it needs no escalation (same posture as manage_backup_profiles). if (action !== 'create') return false; const data = input.data; return typeof data === 'object' && data !== null && !Array.isArray(data) && (data as Record).ownerScope === 'partner'; } if (toolName === 'manage_software_catalog' || toolName === 'manage_custom_fields') { // Full-control W12 (#6782, quorum QW1): mirrors both handlers — only the literal ownerScope 'partner' creates a // partner-wide row (every org of the partner); anything else is org-owned at the base tier 2. const create = toolName === 'manage_software_catalog' ? 'create' : 'create_definition'; return action === create && input.ownerScope === 'partner'; } if (toolName === 'manage_backup_profiles') { // Mirrors the handler (aiToolsPolicyPrereqs.ts create branch): only the // literal 'partner' creates a partner-wide row; anything else is an // org-owned profile, so the guard and the write cannot disagree. return action === 'create' && input.ownerScope === 'partner'; } if (toolName !== 'manage_policy_feature_link') return false; if (action !== 'add' && action !== 'update') return false; return ( input.featureType === 'maintenance' || warrantyHpCmslRequested(input.inlineSettings) ); } /** * Whole-tool counterpart of TIER3_INPUT_AWARE_ACTIONS — base-tier-3 tools * whose scope is resolved from input content rather than TIER3_FOUR_EYES_TOOLS * / TIER3_SUPERVISED_TOOLS membership (e.g. s1_isolate_device's boolean * `isolate`, which has no `action` string to key a per-action pair on). */ export const TIER3_INPUT_AWARE_TOOLS: ReadonlySet = new Set([ 's1_isolate_device', // Full-control W04 (#6758, W04-D3): base tier 2 with no `action` string; // escalated to tier 3 SUPERVISED by isInputAwareTier3 when `evidenceTypes` // asks for a screenshot (the take_screenshot class) or is not an array. 'collect_evidence', // run_script { proposalId }: scope comes from the proposal's REVIEWED risk // tier, handed in through GuardrailContext (AI script authoring, spec §4.5). 'run_script', // Full-control W11 (#6781, W11-D4): base tier 3; its one action's scope comes from the asserted `ownerScope` // (resolveApprovalScope): supervised for an org-owned policy, four-eyes otherwise. 'manage_compliance_policies', ]); /** * Tool Catalog W03 (#5218). Every NON-action input key that an input-aware * branch of `checkGuardrails` / `resolveApprovalScope` reads to pick a tier or * an approval scope. A flow step's tier is fixed at save time, so the flow * validator requires each of these (plus the action selector and any * LIVE_READ_INPUT_FLAGS flag) to be literal — a template must never choose its * own tier at run time. Pinned by aiGuardrails.tierDecidingKeys.contract.test.ts; * a new input-aware branch needs an entry here. * * (event_logs_query's payload-aware classification is chat-session only and * never applies to a flow, which runs without a chat session.) */ export const TIER_DECIDING_INPUT_KEYS: Readonly> = { // isInputAwareTier3: comment + isPublic === true, update_status to // 'resolved' or any statusName, and update_fields carrying a requester key // (inside `fields`) escalate to tier 3 (W02, #6756). manage_tickets: ['isPublic', 'status', 'statusName', 'fields'], // isInputAwareTier3: featureType === 'maintenance' or an HP CMSL inlineSettings payload. manage_policy_feature_link: ['featureType', 'inlineSettings'], // resolveApprovalScope: isolate === false is four-eyes. s1_isolate_device: ['isolate'], // isProposalRunScript: a proposalId changes the tier source entirely. run_script: ['proposalId'], // resolveApprovalScope: update_org with a `status` key is four-eyes. manage_organizations: ['status'], // resolveApprovalScope: sweep above the four-eyes device threshold (or with no count) is four-eyes. ioc_hunt: ['expectedDeviceCount'], // isInputAwareTier3: evidenceTypes containing 'screenshot' (or not an array) is supervised (W04, #6758). collect_evidence: ['evidenceTypes'], // isInputAwareTier3: create with ownerScope === 'partner' is supervised (W03, #6757). manage_backup_profiles: ['ownerScope'], // isInputAwareTier3: create with data.ownerScope === 'partner' is supervised (W06, #6776). manage_ticket_checklist_templates: ['data'], // isInputAwareTier3: create / create_definition with ownerScope === 'partner' is supervised (W12, #6782). manage_software_catalog: ['ownerScope'], manage_custom_fields: ['ownerScope'], // resolveApprovalScope: deactivate is supervised only for ownerScope === 'organization' (W11, #6781). manage_compliance_policies: ['ownerScope'], // resolveApprovalScope: create with ownerScope === 'partner' is four-eyes (W11, #6781). manage_security_policies: ['ownerScope'], }; /** The action selector, the input-aware keys, and the live-read flag, in that order. */ export function tierDecidingInputKeys(toolName: string): string[] { const keys = [TOOL_ACTION_INPUT_KEYS[toolName] ?? 'action', ...(TIER_DECIDING_INPUT_KEYS[toolName] ?? [])]; const liveFlag = LIVE_READ_INPUT_FLAGS[toolName]; if (liveFlag) keys.push(liveFlag); return keys; } /** * Why a proposal-backed `run_script` was refused a tier, when the input named * a `proposalId` but `loadProposalGuardrailContext` could not hand back a * reviewed risk tier for it (#7129). Deliberately collapses "no such id" and * "exists in a different org" into the SAME `proposal_not_found` value — the * caller must never be able to tell a cross-tenant id apart from a * nonexistent one. */ export type ProposalContextDenyReason = | 'proposal_not_found' | 'proposal_review_pending' | 'proposal_review_failed'; /** * Optional, DB-FREE context a caller may hand to the guardrail so an * input-aware decision can read persisted state without this module importing * the schema (aiGuardrails.imports.contract.test.ts). * * Loaded by `loadProposalGuardrailContext` * (services/scriptProposals/guardrailContext.ts) — which is the only producer, * so the risk tier here is always the tier a completed review actually wrote. */ export interface GuardrailContext { proposal?: { riskTier: RiskTier; strictHits: string[] }; /** * Set only when `proposal` is undefined AND the loader actually resolved a * specific reason (vs. no context being supplied at all) — lets the tier-4 * deny below give the caller a model-actionable reason instead of the * generic `proposal_context_missing` catch-all. */ proposalDenyReason?: ProposalContextDenyReason; /** * #7906: set ONLY by the interactive chat gate (`createSessionPreToolUse` / * its postToolUse twin in aiAgentSdk.ts) for a `user_session` principal * that is not a Helper session. Opts the call in to the payload-aware * read-only `event_logs_query` classification. MCP, intents, the agent * catalog and headless runs never set it — `checkAgentGuardrails` strips * it — so they keep the base Tier 3. */ chatSession?: true; } /** A `run_script` call that names a proposal instead of a library script. */ function isProposalRunScript(toolName: string, input: Record): boolean { return toolName === 'run_script' && typeof input.proposalId === 'string' && input.proposalId.length > 0; } export function resolveApprovalScope( toolName: string, action: string | undefined, input: Record, context?: GuardrailContext, ): AiApprovalScope { if (isProposalRunScript(toolName, input)) { // Spec §4.5. No context ⇒ four_eyes, the module's own fail-safe default — // checkGuardrails refuses the call outright a moment later, so this value // is only ever read by a caller that skipped the tier check. Placed BEFORE // the generic TIER3_SUPERVISED_TOOLS hit, which would otherwise resolve // `supervised` for every tier. const tier = context?.proposal?.riskTier; return tier === 'low' || tier === 'medium' ? 'supervised' : 'four_eyes'; } // Input-aware overrides (spec §3.1) — scope depends on argument CONTENT, // not just the tool/action name, so these cannot live in the static // TIER3_*_ACTIONS / TIER3_*_TOOLS tables above. Checked first since neither // pair is (or should be) also listed in a static table. if (toolName === 'manage_organizations' && action === 'update_org') { // A status change (suspend/churn/reactivate) severs or restores agent // tenant access — externally binding, same class as the other // TIER3_FOUR_EYES_ACTIONS members — vs a plain name edit, which is inert. return 'status' in input ? 'four_eyes' : 'supervised'; } if (toolName === 'manage_compliance_policies' && action === 'deactivate') { // W11-D4 (#6781): only an asserted org-owned row is supervised; a partner-wide row, or an assertion that is // missing or not the literal 'organization', is four-eyes. The handler refuses an assertion that does not // match the row, so the cheaper scope never reaches a partner-wide policy. return input.ownerScope === 'organization' ? 'supervised' : 'four_eyes'; } if (toolName === 'manage_security_policies' && action === 'create') { // W11-D13 (#6781): mirrors the handler (createPolicySchema defaults ownerScope to 'organization'): only the // literal 'partner' creates a partner-wide row, which cannot be deleted and applies to every org. return input.ownerScope === 'partner' ? 'four_eyes' : 'supervised'; } if (toolName === 'ioc_hunt' && action === IOC_HUNT_RUN_ACTION) { // #6268 Gate B 6: a sweep the approver sees as "up to N devices" is // supervised up to the four-eyes threshold, four_eyes above it. A count // that is missing or not a non-negative integer cannot be trusted to bound // the sweep, so it takes the stricter scope. createSweep refuses the // released call as a scope change when it would target more devices. const n = input.expectedDeviceCount; return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= iocHuntFourEyesDeviceThreshold() ? 'supervised' : 'four_eyes'; } if (isInputAwareTier3(toolName, action, input)) { // MANDATORY, not stylistic: manage_policy_feature_link is in NEITHER // whole-tool scope set and add/update are in neither *_ACTIONS scope // table, so without this override an escalated add/update would fall all // the way to the per-TOOL `four_eyes` fail-safe at the bottom of this // function. `supervised` matches the #3552/835f7eb3d policy-prerequisite // escalations and manage_configuration_policy's own create/update/delete — // authoring policy configuration, not an externally binding act. The // #5511 hpCmsl arm resolves here too, for the same reason. return 'supervised'; } if (toolName === 's1_isolate_device') { // isolate:false is containment RELEASE (reverses a prior mitigation — // same rationale as s1_threat_action's rollback); isolate:true or // missing is urgent protective containment, which must not wait on a // second approver. return input.isolate === false ? 'four_eyes' : 'supervised'; } if (action && TIER3_FOUR_EYES_ACTIONS[toolName]?.includes(action)) return 'four_eyes'; if (action && TIER3_SUPERVISED_ACTIONS[toolName]?.includes(action)) return 'supervised'; if (TIER3_FOUR_EYES_TOOLS.has(toolName)) return 'four_eyes'; // NOTE: this whole-tool line is reached by an action-multiplexed tool whose // action matched neither *_ACTIONS table — it yields the WEAKER scope, so it // is a backstop, not a fail-safe. The enumeration contract test keeps every // real action of these tools out of this line. if (TIER3_SUPERVISED_TOOLS.has(toolName)) return 'supervised'; // Fail-safe for an unclassified TOOL — including extension tools, which are // per-tenant/dynamic and therefore excluded from getAllRegisteredToolNames() // by design, so they can never be enumerated into the static sets above. return 'four_eyes'; } // RBAC permission map: tool → { resource, action } (or action-based overrides) export const TOOL_PERMISSIONS: Record> = { list_time_entries: { resource: 'time_entries', action: 'read' }, get_running_timer: { resource: 'time_entries', action: 'read' }, get_timesheet: { resource: 'time_entries', action: 'read' }, get_ticket_approval_status: { resource: 'tickets', action: 'read' }, query_devices: { resource: 'devices', action: 'read' }, get_device_details: { resource: 'devices', action: 'read' }, get_device_hardware_health: { resource: 'devices', action: 'read' }, get_device_time_status: { resource: 'devices', action: 'read' }, list_device_restore_point_attempts: { resource: 'devices', action: 'read' }, query_device_workloads: { resource: 'devices', action: 'read' }, list_time_sync_issues: { resource: 'devices', action: 'read' }, get_vulnerability_report: { resource: 'devices', action: 'read' }, get_device_vulnerabilities: { resource: 'devices', action: 'read' }, // routes/patches/operations.ts:29 (/scan) and :171 (/:id/rollback) both // require DEVICES_EXECUTE; `patches` is not a catalog resource. remediate_vulnerability: { resource: 'devices', action: 'execute' }, analyze_metrics: { resource: 'devices', action: 'read' }, get_s1_status: { resource: 'devices', action: 'read' }, get_s1_threats: { resource: 'devices', action: 'read' }, get_edr_status: { resource: 'devices', action: 'read' }, list_edr_detections: { resource: 'devices', action: 'read' }, s1_isolate_device: { resource: 'devices', action: 'execute' }, s1_threat_action: { resource: 'devices', action: 'execute' }, edr_endpoint_action: { resource: 'devices', action: 'execute' }, // Route parity: POST /edr/connections/:id/sync requires ORGS_WRITE. sync_edr_data: { resource: 'organizations', action: 'write' }, execute_command: { resource: 'devices', action: 'execute' }, run_script: { resource: 'scripts', action: 'execute' }, // Authoring is inert, but it is still script work: whoever may read the // library may read a proposal, and whoever may run a script may write one. propose_script: { resource: 'scripts', action: 'execute' }, get_script_proposal: { resource: 'scripts', action: 'read' }, // Same permission the HTTP cancel route requires (PERMISSIONS.SCRIPTS_EXECUTE): // whoever may start a script may stop it, and nobody else. cancel_script_execution: { resource: 'scripts', action: 'execute' }, manage_alerts: { list: { resource: 'alerts', action: 'read' }, get: { resource: 'alerts', action: 'read' }, acknowledge: { resource: 'alerts', action: 'acknowledge' }, resolve: { resource: 'alerts', action: 'write' }, suppress: { resource: 'alerts', action: 'write' }, }, // Per-ACTION shape (like manage_tickets below) so a future action of this // tool cannot inherit the grant permission by accident. `ai_agents:write` // already exists in the canonical registry, seed, migration and catalog. manage_ai_agents: { authorize_supervised_key: { resource: 'ai_agents', action: 'write' }, }, // Same grants as routes/endpointMessages (send.ts requires endpoint_messages:send // + MFA, list.ts requires endpoint_messages:read). MFA is satisfied by the chat // entry point; see the MFA note beside TOOL_ACTION_EXTRA_PERMISSIONS. send_endpoint_message: { send: { resource: 'endpoint_messages', action: 'send' }, list: { resource: 'endpoint_messages', action: 'read' }, get: { resource: 'endpoint_messages', action: 'read' }, }, manage_ticket_routing_rules: { create: { resource: 'tickets', action: 'write' }, update: { resource: 'tickets', action: 'write' }, reorder: { resource: 'tickets', action: 'write' }, delete: { resource: 'tickets', action: 'write' }, }, manage_tickets: { list: { resource: 'tickets', action: 'read' }, get: { resource: 'tickets', action: 'read' }, create: { resource: 'tickets', action: 'write' }, comment: { resource: 'tickets', action: 'write' }, assign: { resource: 'tickets', action: 'write' }, update_status: { resource: 'tickets', action: 'write' }, update_fields: { resource: 'tickets', action: 'write' }, link_alert: { resource: 'tickets', action: 'write' }, unlink_alert: { resource: 'tickets', action: 'write' }, create_from_alert: { resource: 'tickets', action: 'write' }, edit_comment: { resource: 'tickets', action: 'write' }, delete_comment: { resource: 'tickets', action: 'write' }, move_org: { resource: 'tickets', action: 'write' }, // #8972: the staff routes gate both on tickets:write (routes/tickets/merge.ts). merge_preview: { resource: 'tickets', action: 'write' }, merge: { resource: 'tickets', action: 'write' }, log_time_entry: { resource: 'time_entries', action: 'write' }, start_timer: { resource: 'time_entries', action: 'write' }, stop_timer: { resource: 'time_entries', action: 'write' }, update_time_entry: { resource: 'time_entries', action: 'write' }, // P2-4 (#4191): deliberately 'update', not 'write' — no seeded role // grants `tickets:update` (seed.ts only ever grants tickets:read/write/ // manage), so this fails CLOSED for `checkToolPermission`'s interactive // path. link_device/draft are agent-only ticket-triage executors, never // reachable from a live chat/MCP session; the only path that can execute // them is the ai_agent-principal release path, which never consults RBAC // at all (`checkAgentGuardrails`'s doc comment). A future human-facing // caller of these two actions needs a real permission grant added first. link_device: { resource: 'tickets', action: 'update' }, draft: { resource: 'tickets', action: 'update' }, // #8596 — same grants as routes/tickets/links.ts (GET read, POST/DELETE write). list_links: { resource: 'tickets', action: 'read' }, link_ticket: { resource: 'tickets', action: 'write' }, unlink_ticket: { resource: 'tickets', action: 'write' }, // W06 (#6776): routes/tickets/bulk.ts POST /bulk gates on tickets:write. bulk_update: { resource: 'tickets', action: 'write' }, }, // #6930 — same grants as routes/tickets/checklist.ts (TICKETS_READ/WRITE) // and the templates route (reads are TICKETS_READ). manage_ticket_checklist: { list: { resource: 'tickets', action: 'read' }, list_templates: { resource: 'tickets', action: 'read' }, get_template: { resource: 'tickets', action: 'read' }, add_item: { resource: 'tickets', action: 'write' }, update_item: { resource: 'tickets', action: 'write' }, delete_item: { resource: 'tickets', action: 'write' }, reorder: { resource: 'tickets', action: 'write' }, apply_template: { resource: 'tickets', action: 'write' }, save_as_template: { resource: 'tickets', action: 'write' }, duplicate_template: { resource: 'tickets', action: 'write' }, }, // Full-control W06 (#6776) — per-action grants copied from each route (route-binding rows pin them). // routes/orgTicketSettings.ts: ORGS_READ / ORGS_WRITE (+ requireMfa); ticketCategories.ts and // ticketConfig.ts: TICKETS_READ / TICKETS_WRITE (+ the admin predicate, applied in the handler). manage_ticket_config: { get_config: { resource: 'tickets', action: 'read' }, get_org_settings: { resource: 'organizations', action: 'read' }, list_categories: { resource: 'tickets', action: 'read' }, list_domains: { resource: 'tickets', action: 'read' }, update_org_settings: { resource: 'organizations', action: 'write' }, create_category: { resource: 'tickets', action: 'write' }, update_category: { resource: 'tickets', action: 'write' }, delete_category: { resource: 'tickets', action: 'write' }, reorder_categories: { resource: 'tickets', action: 'write' }, create_status: { resource: 'tickets', action: 'write' }, update_status: { resource: 'tickets', action: 'write' }, reorder_statuses: { resource: 'tickets', action: 'write' }, set_priorities: { resource: 'tickets', action: 'write' }, create_domain: { resource: 'tickets', action: 'write' }, update_domain: { resource: 'tickets', action: 'write' }, delete_domain: { resource: 'tickets', action: 'write' }, }, manage_ticket_checklist_templates: { list: { resource: 'tickets', action: 'read' }, get: { resource: 'tickets', action: 'read' }, create: { resource: 'tickets', action: 'write' }, update: { resource: 'tickets', action: 'write' }, delete: { resource: 'tickets', action: 'write' }, add_item: { resource: 'tickets', action: 'write' }, update_item: { resource: 'tickets', action: 'write' }, remove_item: { resource: 'tickets', action: 'write' }, reorder_items: { resource: 'tickets', action: 'write' }, }, // routes/ticketConfig.ts gates the email-inbound LIST on tickets:write (writePerm), like its mutations. manage_ticket_inbox: { list: { resource: 'tickets', action: 'write' }, convert: { resource: 'tickets', action: 'write' }, dismiss: { resource: 'tickets', action: 'write' }, }, manage_ticket_attachments: { list: { resource: 'tickets', action: 'read' }, read_content: { resource: 'tickets', action: 'read' }, attach_from_artifact: { resource: 'tickets', action: 'write' }, delete: { resource: 'tickets', action: 'write' }, }, manage_ticket_forms: { list: { resource: 'tickets', action: 'read' }, available: { resource: 'tickets', action: 'read' }, create: { resource: 'tickets', action: 'write' }, update: { resource: 'tickets', action: 'write' }, delete: { resource: 'tickets', action: 'write' }, }, manage_ticket_response_templates: { list: { resource: 'tickets', action: 'read' }, create: { resource: 'tickets', action: 'write' }, update: { resource: 'tickets', action: 'write' }, delete: { resource: 'tickets', action: 'write' }, }, manage_time_suggestions: { list: { resource: 'time_entries', action: 'read' }, confirm: { resource: 'time_entries', action: 'write' }, dismiss: { resource: 'time_entries', action: 'write' }, undo: { resource: 'time_entries', action: 'write' }, }, // Full-control W07 (#6777) — per-action grants copied from each route (route-binding rows pin them). // routes/alerts/correlations.ts + routes/alertTemplates/correlations.ts: every read is alerts:read. get_alert_correlations: { resource: 'alerts', action: 'read' }, // acknowledge: requireAlertAcknowledge; resolve: requireAlertWrite; both feedback routes: requireAlertRead. manage_alert_correlations: { acknowledge_group: { resource: 'alerts', action: 'acknowledge' }, resolve_group: { resource: 'alerts', action: 'write' }, acknowledge_related: { resource: 'alerts', action: 'acknowledge' }, feedback: { resource: 'alerts', action: 'read' }, rca_feedback: { resource: 'alerts', action: 'read' }, }, // routes/devices/anomalies.ts: list is devices:read; the status PATCH (all three statuses) is alerts:write. manage_device_anomalies: { list: { resource: 'devices', action: 'read' }, dismiss: { resource: 'alerts', action: 'write' }, resolve: { resource: 'alerts', action: 'write' }, promote: { resource: 'alerts', action: 'write' }, }, // routes/reliability.ts POST /:deviceId/feedback is gated on devices:read (requireReliabilityRead). reliability_feedback: { resource: 'devices', action: 'read' }, list_invoices: { resource: 'invoices', action: 'read' }, get_invoice: { resource: 'invoices', action: 'read' }, manage_invoices: { create_draft: { resource: 'invoices', action: 'write' }, add_manual_line: { resource: 'invoices', action: 'write' }, add_catalog_line: { resource: 'invoices', action: 'write' }, add_bundle_line: { resource: 'invoices', action: 'write' }, add_contract_line: { resource: 'invoices', action: 'write' }, update_line: { resource: 'invoices', action: 'write' }, remove_line: { resource: 'invoices', action: 'write' }, update_header: { resource: 'invoices', action: 'write' }, delete_draft: { resource: 'invoices', action: 'write' }, assemble_from_org: { resource: 'invoices', action: 'write' }, assemble_from_ticket: { resource: 'invoices', action: 'write' }, refresh_time_lines: { resource: 'invoices', action: 'write' }, issue: { resource: 'invoices', action: 'send' }, void: { resource: 'invoices', action: 'send' }, record_payment: { resource: 'invoices', action: 'send' }, void_payment: { resource: 'invoices', action: 'send' }, create_pay_link: { resource: 'invoices', action: 'write' }, }, search_catalog: { resource: 'catalog', action: 'read' }, get_catalog_item: { resource: 'catalog', action: 'read' }, lookup_distributor_product: { resource: 'catalog', action: 'read' }, manage_billing_catalog: { create_item: { resource: 'catalog', action: 'write' }, update_item: { resource: 'catalog', action: 'write' }, // Matches POST /catalog/items/:id/archive // (routes/catalog/catalog.ts:24,75 — `deletePerm`, i.e. catalog:delete). archive_item: { resource: 'catalog', action: 'delete' }, // W02-D5 (#6756): implemented and documented but unmapped, so // resolveToolPermissionRequirements failed closed with "Unknown action". // PUT/DELETE /catalog/items/:id/prices/:currencyCode (catalog/pricing.ts) are catalog:write. set_price: { resource: 'catalog', action: 'write' }, remove_price: { resource: 'catalog', action: 'write' }, set_org_price: { resource: 'catalog', action: 'write' }, remove_org_price: { resource: 'catalog', action: 'write' }, set_bundle_components: { resource: 'catalog', action: 'write' }, }, list_contracts: { resource: 'contracts', action: 'read' }, get_contract: { resource: 'contracts', action: 'read' }, manage_contracts: { create_draft: { resource: 'contracts', action: 'write' }, update: { resource: 'contracts', action: 'write' }, delete_draft: { resource: 'contracts', action: 'write' }, add_line: { resource: 'contracts', action: 'write' }, remove_line: { resource: 'contracts', action: 'write' }, update_line: { resource: 'contracts', action: 'write' }, activate: { resource: 'contracts', action: 'manage' }, pause: { resource: 'contracts', action: 'manage' }, resume: { resource: 'contracts', action: 'manage' }, cancel: { resource: 'contracts', action: 'manage' }, }, // Service deliverables W02 (#5573 spec §10). `contracts`, not a new resource: // the REST routes for deliverables AND key dates gate on contracts:read / // contracts:write, and the AI door must not disagree with the HTTP door. list_deliverables: { resource: 'contracts', action: 'read' }, list_deliverable_templates: { resource: 'contracts', action: 'read' }, manage_deliverables: { create: { resource: 'contracts', action: 'write' }, update: { resource: 'contracts', action: 'write' }, deactivate: { resource: 'contracts', action: 'write' }, deliver: { resource: 'contracts', action: 'write' }, waive: { resource: 'contracts', action: 'write' }, reopen: { resource: 'contracts', action: 'write' }, reschedule: { resource: 'contracts', action: 'write' }, link_evidence: { resource: 'contracts', action: 'write' }, // `manage`, not `write`: applying a template stands up a whole schedule at // once, matching the contracts lifecycle actions above. apply_template: { resource: 'contracts', action: 'manage' }, }, manage_key_dates: { list: { resource: 'contracts', action: 'read' }, create: { resource: 'contracts', action: 'write' }, update: { resource: 'contracts', action: 'write' }, delete: { resource: 'contracts', action: 'write' }, }, // Org document library (service deliverables W03): its own resource, not // `contracts` — a technician may file documents without billing authority. list_org_documents: { resource: 'documents', action: 'read' }, manage_org_documents: { update_metadata: { resource: 'documents', action: 'write' }, set_portal_visibility: { resource: 'documents', action: 'write' }, supersede: { resource: 'documents', action: 'write' }, }, list_quotes: { resource: 'quotes', action: 'read' }, get_quote: { resource: 'quotes', action: 'read' }, manage_quotes: { create_draft: { resource: 'quotes', action: 'write' }, update: { resource: 'quotes', action: 'write' }, delete_draft: { resource: 'quotes', action: 'write' }, add_block: { resource: 'quotes', action: 'write' }, update_block: { resource: 'quotes', action: 'write' }, delete_block: { resource: 'quotes', action: 'write' }, reorder_blocks: { resource: 'quotes', action: 'write' }, add_manual_line: { resource: 'quotes', action: 'write' }, add_catalog_line: { resource: 'quotes', action: 'write' }, update_line: { resource: 'quotes', action: 'write' }, remove_line: { resource: 'quotes', action: 'write' }, move_line: { resource: 'quotes', action: 'write' }, reorder_lines: { resource: 'quotes', action: 'write' }, send: { resource: 'quotes', action: 'send' }, // Recording the customer's decision (however it // came in) is the same authority whichever way they answered: matches // accept-on-behalf and the route's own gate (routes/quotes/lifecycle.ts: // 30, 253-254 — decline-on-behalf is `acceptPerm`, i.e. quotes:accept). decline: { resource: 'quotes', action: 'accept' }, // No staff REST twin for THIS action; the matching // staff authority is POST /invoices/:id/pay-link (routes/invoices/stripe.ts: // 19,26), gated on invoices:send. A Stripe checkout session is created on // the partner's connected account, same as that route. `quotes:read` extra // below because the handler still has to read the quote to resolve it. create_pay_link: { resource: 'invoices', action: 'send' }, }, // GET orgContacts.ts /organizations/:id/contacts: PERMISSIONS.ORGS_READ. // GET remediationSuggestions.ts /: PERMISSIONS.DEVICES_READ. list_remediation_suggestions: { resource: 'devices', action: 'read' }, // GET remediationSuggestions.ts /: PERMISSIONS.DEVICES_READ — proven fixes are // the same class of read as the suggestions list they feed. find_proven_fixes: { resource: 'devices', action: 'read' }, list_incidents: { resource: 'alerts', action: 'read' }, list_ai_agents: { resource: 'ai_agents', action: 'read' }, list_ai_agent_runs: { resource: 'ai_agents', action: 'read' }, get_ai_agent_run: { resource: 'ai_agents', action: 'read' }, // A-W05 (D13a): same permission as the REST download (GET /ai/artifacts/:id, // routes/aiArtifacts.ts) — the tool's scope is stricter (own run/session // only), not its permission. read_artifact: { resource: 'ai_agents', action: 'read' }, // Same permission as the editor's GET /orgs/:orgId/trust-page. get_trust_page_status: { resource: 'organizations', action: 'read' }, // Tool Catalog W03 (#5218): flows reuse the automations permissions. draft_flow: { resource: 'automations', action: 'write' }, validate_flow: { resource: 'automations', action: 'read' }, get_flow: { resource: 'automations', action: 'read' }, list_flows: { resource: 'automations', action: 'read' }, // Tool Catalog W04 (#5219): flow runs. run_flow: { resource: 'automations', action: 'write' }, list_flow_runs: { resource: 'automations', action: 'read' }, get_flow_run: { resource: 'automations', action: 'read' }, cancel_flow_run: { resource: 'automations', action: 'write' }, // #8973 W4: business-hours calendars read with the ticket permission. list_business_calendars: { resource: 'tickets', action: 'read' }, // #8883 W5: ticket routing rules (reads here; manage_ticket_routing_rules is per-action below). list_ticket_routing_rules: { resource: 'tickets', action: 'read' }, test_ticket_routing_rule: { resource: 'tickets', action: 'read' }, // Wisp W03 memory (services/wisp/memoryTools.ts): the chat-use permission // (routes/ai.ts), same as the /wisp/memory routes. confirm/retract are // HUMAN_ONLY_TOOLS; mapped so a direct dispatch still carries an RBAC gate. recall: { resource: 'ai_sessions', action: 'use' }, remember: { resource: 'ai_sessions', action: 'use' }, confirm_memory: { resource: 'ai_sessions', action: 'use' }, retract_memory: { resource: 'ai_sessions', action: 'use' }, // Wisp W05a concerns/watches (services/wisp/concernTools.ts), w00 A1: the // chat-use permission, same as the /wisp/concerns and /wisp/watches routes. list_concerns: { resource: 'ai_sessions', action: 'use' }, raise_concern: { resource: 'ai_sessions', action: 'use' }, snooze_concern: { resource: 'ai_sessions', action: 'use' }, resolve_concern: { resource: 'ai_sessions', action: 'use' }, create_watch: { resource: 'ai_sessions', action: 'use' }, // Wisp W08 thread links (services/wisp/linkTools.ts): the chat-use // permission, same as the /wisp/thread/:id/links routes. The object's own // read permission is checked per object by resolveEntityRef. link_entity: { resource: 'ai_sessions', action: 'use' }, list_links: { resource: 'ai_sessions', action: 'use' }, // Wisp W07 specialists (services/aiToolsWisp.ts), w00 A1: the chat-use // permission. The kind's own permissions (SPECIALIST_KIND_POLICY) are // ceiling terms the coordinator checks against the grantor AND the acting // human — not tool gates. launch_specialist: { resource: 'ai_sessions', action: 'use' }, wisp_specialist_request: { resource: 'ai_sessions', action: 'use' }, list_sites: { resource: 'sites', action: 'read' }, get_site: { resource: 'sites', action: 'read' }, list_org_contacts: { resource: 'organizations', action: 'read' }, list_organizations: { resource: 'organizations', action: 'read' }, manage_organizations: { create_org: { resource: 'organizations', action: 'write' }, update_org: { resource: 'organizations', action: 'write' }, create_site: { resource: 'sites', action: 'write' }, add_contact: { resource: 'organizations', action: 'write' }, }, manage_services: { resource: 'devices', action: 'execute' }, manage_processes: { // Live inspection: list dispatches list_processes to the agent, the same // command GET /system-tools/devices/:id/processes sends, and that router // requires DEVICES_EXECUTE on every method (routes/systemTools/index.ts). list: { resource: 'devices', action: 'execute' }, kill: { resource: 'devices', action: 'execute' }, }, security_scan: { scan: { resource: 'devices', action: 'execute' }, status: { resource: 'devices', action: 'execute' }, quarantine: { resource: 'devices', action: 'execute' }, remove: { resource: 'devices', action: 'execute' }, restore: { resource: 'devices', action: 'execute' }, vulnerabilities: { resource: 'devices', action: 'read' }, }, // #6268: mirrors routes/security/sweeps.ts (reads devices:read, create/cancel // devices:execute) and routes/security/yaraIocImport.ts (devices:write). ioc_hunt: { list_rule_sets: { resource: 'devices', action: 'read' }, preview_sweep: { resource: 'devices', action: 'read' }, list_sweeps: { resource: 'devices', action: 'read' }, sweep_status: { resource: 'devices', action: 'read' }, sweep: { resource: 'devices', action: 'execute' }, cancel_sweep: { resource: 'devices', action: 'execute' }, import_iocs: { resource: 'devices', action: 'write' }, }, // Stored snapshot read. `refresh: true` scans the device live and requires // devices:execute instead (LIVE_READ_INPUT_FLAGS; POST // /devices/:id/filesystem/scan). analyze_disk_usage: { resource: 'devices', action: 'read' }, disk_cleanup: { preview: { resource: 'devices', action: 'read' }, execute: { resource: 'devices', action: 'execute' }, }, system_cleanup: { // Live: asks the device for its cleaner catalog, like POST // /devices/:id/filesystem/system-cleanup/list (DEVICES_EXECUTE). list: { resource: 'devices', action: 'execute' }, run: { resource: 'devices', action: 'execute' }, status: { resource: 'devices', action: 'read' }, }, file_operations: { // Privileged-read rule: read/list require devices.execute (not devices.read). Reading an // arbitrary file off a root/LocalSystem agent is a privileged operation. list: { resource: 'devices', action: 'execute' }, read: { resource: 'devices', action: 'execute' }, write: { resource: 'devices', action: 'execute' }, delete: { resource: 'devices', action: 'execute' }, mkdir: { resource: 'devices', action: 'execute' }, rename: { resource: 'devices', action: 'execute' }, }, query_audit_log: { resource: 'audit', action: 'read' }, query_change_log: { resource: 'devices', action: 'read' }, network_discovery: { resource: 'devices', action: 'execute' }, // Stored boot history. `triggerCollection: true` collects live from the // device and requires devices:execute instead (LIVE_READ_INPUT_FLAGS; POST // /devices/:id/collect-boot-metrics). analyze_boot_performance: { resource: 'devices', action: 'read' }, manage_startup_items: { resource: 'devices', action: 'execute' }, manage_scheduled_tasks: { // Live inspection: list dispatches tasks_list to the agent, like GET // /system-tools/devices/:id/tasks (DEVICES_EXECUTE router-wide). list: { resource: 'devices', action: 'execute' }, run: { resource: 'devices', action: 'execute' }, disable: { resource: 'devices', action: 'execute' }, enable: { resource: 'devices', action: 'execute' }, }, take_screenshot: { resource: 'devices', action: 'execute' }, analyze_screen: { resource: 'devices', action: 'execute' }, computer_control: { resource: 'devices', action: 'execute' }, // Fleet tools — RBAC mappings // Mirrors routes/deployments.ts exactly: GET / and GET /:id and // GET /:id/devices -> requireDeploymentRead (DEVICES_READ, L20/225/346/740); // POST / -> requireDeploymentWrite (DEVICES_WRITE, L21/276); and // /:id/{start,pause,resume,cancel} -> requireDeploymentExecute // (DEVICES_EXECUTE, L22/531/585/635/685). The previous `deployments:*` // resource does not exist in the canonical catalog (#6103 / 2026-09-17 audit // §2.6) so these actions were reachable only by an `*:*` grant. manage_deployments: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, device_status: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, start: { resource: 'devices', action: 'execute' }, pause: { resource: 'devices', action: 'execute' }, resume: { resource: 'devices', action: 'execute' }, cancel: { resource: 'devices', action: 'execute' }, }, // Mirrors routes/patches/*: GET /compliance and GET /approvals -> // DEVICES_READ (compliance.ts:43, approvals.ts:37); /scan and /:id/rollback // -> DEVICES_EXECUTE (operations.ts:29, 171); /bulk-approve, /:id/approve, // /:id/decline, /:id/defer -> DEVICES_EXECUTE (approvals.ts:87, 156, 217, // 308). `setup_auto_approval` writes standing approval authority into a // configuration policy, so it rides with the approve family on // DEVICES_EXECUTE rather than the weaker DEVICES_WRITE of the policy CRUD // route. GET /patches itself carries no requirePermission (list.ts:34-36, // scope-only); DEVICES_READ is used here so the tool is never WEAKER than // its sibling reads. The previous `patches:*` resource is not in the // canonical catalog (2026-09-17 audit §2.6). manage_patches: { list: { resource: 'devices', action: 'read' }, compliance: { resource: 'devices', action: 'read' }, // #6665: read-only per-device patch job history (scheduled + user // initiated) — same DEVICES_READ tier as list/compliance above. device_history: { resource: 'devices', action: 'read' }, scan: { resource: 'devices', action: 'execute' }, approve: { resource: 'devices', action: 'execute' }, decline: { resource: 'devices', action: 'execute' }, defer: { resource: 'devices', action: 'execute' }, bulk_approve: { resource: 'devices', action: 'execute' }, install: { resource: 'devices', action: 'execute' }, rollback: { resource: 'devices', action: 'execute' }, setup_auto_approval: { resource: 'devices', action: 'execute' }, }, // Mirrors routes/groups.ts:25-26 — requireGroupRead = DEVICES_READ, // requireGroupWrite = DEVICES_WRITE (DELETE /:id also uses // requireGroupWrite, L781-784). `groups` is not a catalog resource. manage_groups: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, preview: { resource: 'devices', action: 'read' }, membership_log: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, add_devices: { resource: 'devices', action: 'write' }, remove_devices: { resource: 'devices', action: 'write' }, }, // Mirrors routes/maintenance.ts:25-26 — requireMaintenanceRead = // DEVICES_READ, requireMaintenanceWrite = DEVICES_WRITE. `maintenance` // is not a catalog resource. manage_maintenance_windows: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, active_now: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, }, manage_automations: { list: { resource: 'automations', action: 'read' }, get: { resource: 'automations', action: 'read' }, history: { resource: 'automations', action: 'read' }, create: { resource: 'automations', action: 'write' }, update: { resource: 'automations', action: 'write' }, delete: { resource: 'automations', action: 'write' }, enable: { resource: 'automations', action: 'write' }, disable: { resource: 'automations', action: 'write' }, // routes/automations.ts:1615-1617 — POST /:id/run gates on // requireAutomationWrite (AUTOMATIONS_WRITE). `automations:execute` is // not a registered action on the `automations` resource. run: { resource: 'automations', action: 'write' }, }, manage_alert_rules: { list_rules: { resource: 'alerts', action: 'read' }, get_rule: { resource: 'alerts', action: 'read' }, create_rule: { resource: 'alerts', action: 'write' }, update_rule: { resource: 'alerts', action: 'write' }, delete_rule: { resource: 'alerts', action: 'write' }, test_rule: { resource: 'alerts', action: 'read' }, list_channels: { resource: 'alerts', action: 'read' }, alert_summary: { resource: 'alerts', action: 'read' }, }, // Mirrors routes/monitoring.ts:26 — requireMonitoringRead = DEVICES_READ. // `monitoring` is not a catalog resource. manage_service_monitors: { list: { resource: 'devices', action: 'read' }, }, generate_report: { list: { resource: 'reports', action: 'read' }, // routes/reports/generate.ts:24 gates POST /reports/generate on // REPORTS_EXPORT, not REPORTS_WRITE — rendering a report materialises org // data into a downloadable artifact. The seeded `Org Technician` holds // reports:read/reports:write and NOT reports:export (db/seed.ts:444), so // the old mapping let a seeded role generate reports it cannot generate // over HTTP (2026-09-17 audit §2.5). generate: { resource: 'reports', action: 'export' }, data: { resource: 'reports', action: 'read' }, create: { resource: 'reports', action: 'write' }, update: { resource: 'reports', action: 'write' }, delete: { resource: 'reports', action: 'write' }, history: { resource: 'reports', action: 'read' }, download: { resource: 'reports', action: 'read' }, }, // Analytics tools query_analytics: { resource: 'devices', action: 'read' }, get_executive_summary: { resource: 'devices', action: 'read' }, // Brain device context tools get_device_context: { resource: 'devices', action: 'read' }, set_device_context: { resource: 'devices', action: 'write' }, resolve_device_context: { resource: 'devices', action: 'write' }, // Agent log tools search_agent_logs: { resource: 'devices', action: 'read' }, // Execution plane W04 — sandbox workspace tools. They only function inside // an `analysis` run (chat/MCP calls return `workspace_requires_run`); the // mapping exists so the chat path reports that typed error rather than // "No RBAC permission mapping". // // CLOSED (2026-09-17 AI tool ROLE audit §2.7 landmine): raised to the // execute-class `ai_workspace:execute` by Wisp W02 (spec §4.6) — the old // flat `ai_agents:read` was a low-bar grant seeded Org Admin holds, while // `workspace_run` executes arbitrary bash / python / node. Partner Admin // holds the new key through `*:*`; no other role is seeded with it. // `resolveWorkspace` (workspace/workspaceTools.ts) still answers non-agent // principals with `workspace_requires_run`, and agent principals still // bypass this table. Pinned by aiGuardrails.workspaceToolSurface.contract.test.ts. workspace_stage: { resource: 'ai_workspace', action: 'execute' }, workspace_run: { resource: 'ai_workspace', action: 'execute' }, workspace_collect: { resource: 'ai_workspace', action: 'execute' }, workspace_cancel: { resource: 'ai_workspace', action: 'execute' }, set_agent_log_level: { resource: 'devices', action: 'execute' }, capture_agent_pprof: { resource: 'devices', action: 'execute' }, // Event log tools search_logs: { resource: 'devices', action: 'read' }, get_log_trends: { resource: 'devices', action: 'read' }, detect_log_correlations: { resource: 'devices', action: 'read' }, // Execution plane export_dataset: { resource: 'devices', action: 'read' }, // Configuration policy tools list_configuration_policies: { resource: 'devices', action: 'read' }, get_configuration_policy: { resource: 'devices', action: 'read' }, manage_configuration_policy: { create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, activate: { resource: 'devices', action: 'write' }, deactivate: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, }, configuration_policy_compliance: { summary: { resource: 'devices', action: 'read' }, status: { resource: 'devices', action: 'read' }, }, get_effective_configuration: { resource: 'devices', action: 'read' }, preview_configuration_change: { resource: 'devices', action: 'read' }, manage_policy_feature_link: { describe: { resource: 'devices', action: 'read' }, list: { resource: 'devices', action: 'read' }, add: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, remove: { resource: 'devices', action: 'write' }, }, // Monitor definition tools (#5289 Task 8) — same permission the HTTP routes // require (PERMISSIONS.ALERTS_READ / ALERTS_WRITE, routes/monitorDefinitions.ts). // NOTE: the write tool is manage_monitor_definitions, NOT manage_monitors — // that name is already taken by the unrelated network-monitor CRUD tool // (see the "Monitoring tools" RBAC mappings below). list_monitors: { resource: 'alerts', action: 'read' }, get_monitor: { resource: 'alerts', action: 'read' }, // #5290 (W03): get_monitor_activity is read-only (episode/state history); // reset_monitor_escalation mutates the escalation latch, so it needs write. get_monitor_activity: { resource: 'alerts', action: 'read' }, reset_monitor_escalation: { resource: 'alerts', action: 'write' }, manage_monitor_definitions: { create: { resource: 'alerts', action: 'write' }, update: { resource: 'alerts', action: 'write' }, delete: { resource: 'alerts', action: 'write' }, enable: { resource: 'alerts', action: 'write' }, disable: { resource: 'alerts', action: 'write' }, attach: { resource: 'alerts', action: 'write' }, detach: { resource: 'alerts', action: 'write' }, }, // Mirrors routes/backup/profiles.ts — reads gate on BACKUP_READ, writes on // BACKUP_WRITE (profiles.ts:91/135/149/208/256; configs.ts:227/246/ // 339/362/504/541). NOT the generic device/policy grants: backup // profile and config writes arm retention and storage credentials. manage_backup_profiles: { list: { resource: 'backup', action: 'read' }, get: { resource: 'backup', action: 'read' }, create: { resource: 'backup', action: 'write' }, update: { resource: 'backup', action: 'write' }, delete: { resource: 'backup', action: 'write' }, }, apply_configuration_policy: { resource: 'devices', action: 'write' }, remove_configuration_policy_assignment: { resource: 'devices', action: 'write' }, // Playbook tools list_playbooks: { resource: 'devices', action: 'read' }, execute_playbook: { resource: 'devices', action: 'execute' }, get_playbook_history: { resource: 'devices', action: 'read' }, // Security + reliability read tools get_security_posture: { resource: 'devices', action: 'read' }, get_fleet_health: { resource: 'devices', action: 'read' }, get_fleet_device_stats: { resource: 'devices', action: 'read' }, get_invite_funnel: { resource: 'devices', action: 'read' }, // Fleet hygiene findings (Task 8) — read-only, mirrors the // GET /fleet/findings route's requireFindingsRead (DEVICES_READ) gate. get_fleet_findings: { resource: 'devices', action: 'read' }, analyze_fleet_metrics: { resource: 'devices', action: 'read' }, // Tenant lifecycle (tier 3 destructive, typed-confirmation gated in handler). // Written as `organizations:write` so any partner admin with org write access // can call it; the handler additionally enforces tenant_id == auth.partnerId. delete_tenant: { resource: 'organizations', action: 'write' }, // Tags, custom fields, and registry tools manage_tags: { list: { resource: 'devices', action: 'read' }, add: { resource: 'devices', action: 'write' }, remove: { resource: 'devices', action: 'write' }, }, query_custom_fields: { resource: 'devices', action: 'read' }, registry_operations: { // Privileged-read precedent, applied (2026-09-17 audit §2.4). These are not device // reads: the handler dispatches a real agent command // (aiToolsScripts.ts, aiExecuteCommand(auth, 'registry_operations', …)), // and the HTTP path for agent commands requires DEVICES_EXECUTE plus // requireMfa() (routes/devices/commands.ts:49). Registry values are the // same exfiltration class the comment at the top of this file names for // file_read and list_services — autologon DefaultPassword, service // configs, connection strings — off a root/LocalSystem agent. Matches the // treatment file_operations.list/.read already received above. // Tier: both are in TIER2_ACTIONS, and the tool's base registration was // raised 1 -> 2 in aiToolsScripts.ts so an unclassified action cannot land // on Tier 1. read_key: { resource: 'devices', action: 'execute' }, get_value: { resource: 'devices', action: 'execute' }, set_value: { resource: 'devices', action: 'execute' }, create_key: { resource: 'devices', action: 'execute' }, delete_key: { resource: 'devices', action: 'execute' }, }, // Documentation tools // `general` was never a catalog resource. The documentation index is // static product content, not tenant data, and the only surface that // reaches this tool is the AI chat route, which requires ai_sessions:use // (routes/ai.ts, #6396) — so mirroring that is exactly "what the route // requires" and nothing weaker. It used to mirror organizations:read, which // the seeded Org Admin / Org Technician roles do NOT hold: chat would open // and the first docs lookup would be denied. search_documentation: { resource: 'ai_sessions', action: 'use' }, // Script library tools search_script_library: { resource: 'scripts', action: 'read' }, list_scripts: { resource: 'scripts', action: 'read' }, get_script_details: { resource: 'scripts', action: 'read' }, list_script_templates: { resource: 'scripts', action: 'read' }, get_script_execution_history: { resource: 'scripts', action: 'read' }, get_script_execution: { resource: 'scripts', action: 'read' }, // Backup & DR tools // Backup/vault/Hyper-V/MSSQL reads mirror their routes, which own these // surfaces on `organizations:*` and `backup:*` rather than the device grants // the data's shape suggests (2026-09-17 audit §2.5 — one decision, nine // tools). Route evidence is cited per line. // Per-action map: list_configs mirrors routes/backup/configs.ts:232 // (backup:read), list_jobs mirrors routes/backup/jobs.ts:61 (organizations:read), // list_policies mirrors routes/configurationPolicies/crud.ts's GET / (devices:read). // Bound in aiGuardrails.routeBinding.contract.test.ts. query_backups: { list_configs: { resource: 'backup', action: 'read' }, list_jobs: { resource: 'organizations', action: 'read' }, list_policies: { resource: 'devices', action: 'read' }, }, get_backup_status: { resource: 'organizations', action: 'read' }, // routes/backup/jobs.ts:147 browse_snapshots: { resource: 'backup', action: 'read' }, // routes/backup/snapshots.ts:200, 273 trigger_backup: { resource: 'devices', action: 'execute' }, restore_snapshot: { resource: 'devices', action: 'execute' }, restore_as_vm: { resource: 'devices', action: 'execute' }, instant_boot_vm: { resource: 'devices', action: 'execute' }, get_vm_restore_estimate: { resource: 'backup', action: 'read' }, // routes/backup/vmrestore.ts:501 query_mssql_instances: { resource: 'organizations', action: 'read' }, // routes/backup/mssql.ts:67 get_mssql_backup_status: { resource: 'organizations', action: 'read' }, // routes/backup/mssql.ts:94, 127 trigger_mssql_backup: { resource: 'devices', action: 'execute' }, restore_mssql_database: { resource: 'devices', action: 'execute' }, verify_mssql_backup: { resource: 'devices', action: 'execute' }, query_hyperv_vms: { resource: 'organizations', action: 'read' }, // routes/backup/hyperv.ts:52 get_hyperv_vm_details: { resource: 'organizations', action: 'read' }, // routes/backup/hyperv.ts:94, 124 manage_hyperv_vm: { resource: 'devices', action: 'execute' }, trigger_hyperv_backup: { resource: 'devices', action: 'execute' }, restore_hyperv_vm: { resource: 'devices', action: 'execute' }, manage_hyperv_checkpoints: { resource: 'devices', action: 'execute' }, query_vaults: { resource: 'organizations', action: 'read' }, // routes/backup/vault.ts:100 get_vault_status: { resource: 'organizations', action: 'read' }, // routes/backup/vault.ts:371 trigger_vault_sync: { resource: 'devices', action: 'execute' }, // routes/backup/vault.ts:272 — unchanged, already parity // WRITES storage/credential configuration; routes/backup/vault.ts:135, 186, // 231 all require ORGS_WRITE. The seeded Org Technician holds devices:write // and no organizations:*, so the old mapping was a seeded-role write escalation. configure_vault: { resource: 'organizations', action: 'write' }, m365_query_users: { resource: 'organizations', action: 'read' }, m365_query_signins: { resource: 'organizations', action: 'read' }, m365_query_intune_devices: { resource: 'organizations', action: 'read' }, m365_query_groups: { resource: 'organizations', action: 'read' }, m365_query_org: { resource: 'organizations', action: 'read' }, m365_query_sites: { resource: 'organizations', action: 'read' }, query_c2c_connections: { resource: 'organizations', action: 'read' }, query_c2c_jobs: { resource: 'organizations', action: 'read' }, search_c2c_items: { resource: 'organizations', action: 'read' }, trigger_c2c_sync: { resource: 'organizations', action: 'write' }, restore_c2c_items: { resource: 'organizations', action: 'write' }, query_backup_sla: { resource: 'organizations', action: 'read' }, get_sla_breaches: { resource: 'organizations', action: 'read' }, get_sla_compliance_report: { resource: 'organizations', action: 'read' }, configure_backup_sla: { resource: 'organizations', action: 'write' }, // Route requires devices:read (routes/dr.ts:34-37, requireDrRead); the // tool matches it (aiGuardrails.routeBinding.contract.test.ts). query_dr_plans: { resource: 'devices', action: 'read' }, get_dr_plan_details: { resource: 'devices', action: 'read' }, get_dr_execution_status: { resource: 'devices', action: 'read' }, execute_dr_plan: { resource: 'devices', action: 'execute' }, // Mirrors routes/dr.ts:38 (requireDrWrite = devices:write) on every plan and // group write. manage_dr_plan: { resource: 'devices', action: 'write' }, // Monitoring tools — RBAC mappings query_monitors: { resource: 'devices', action: 'read' }, manage_monitors: { get: { resource: 'devices', action: 'read' }, // Retired mutations return a refusal before DB access, so they need no write permission. create: { resource: 'devices', action: 'read' }, update: { resource: 'devices', action: 'read' }, delete: { resource: 'devices', action: 'read' }, }, get_service_monitoring_status: { resource: 'devices', action: 'read' }, // Integration & webhook tools // Matches REST GET /webhooks and GET /webhooks/:id/deliveries // (routes/webhooks.ts — webhooks:read), bound in // aiGuardrails.routeBinding.contract.test.ts. query_webhooks: { resource: 'webhooks', action: 'read' }, // Route requires organizations:read (routes/psa.ts:435); the tool matches // it (aiGuardrails.routeBinding.contract.test.ts). query_psa_status: { resource: 'organizations', action: 'read' }, // Matches POST /webhooks/:id/test // (routes/webhooks.ts:683-684 — organizations:write, plus requireMfa(); // see the MFA note beside TOOL_EXTRA_PERMISSIONS). The tool is tier 3 // supervised (TIER3_SUPERVISED_TOOLS) because the route mutates and // requires MFA. test_webhook: { resource: 'organizations', action: 'write' }, // Agent version & remote session tools query_agent_versions: { resource: 'devices', action: 'read' }, trigger_agent_upgrade: { resource: 'devices', action: 'execute' }, // Mirrors routes/devices/agentRecovery.ts POST /:id/agent-recovery, which gates on // devices:recover_agent (PERMISSIONS.DEVICES_RECOVER_AGENT), not devices:execute // (W04 route-binding, aiGuardrails.routeBinding.contract.test.ts). trigger_agent_restart: { resource: 'devices', action: 'recover_agent' }, list_remote_sessions: { resource: 'devices', action: 'read' }, create_remote_session: { resource: 'devices', action: 'execute' }, // Compliance policy tools query_compliance_policies: { resource: 'devices', action: 'read' }, get_compliance_status: { resource: 'devices', action: 'read' }, // Notification channel tools manage_delivery: { resolve: { resource: 'alerts', action: 'read' }, list_routing: { resource: 'alerts', action: 'read' }, list_escalation: { resource: 'alerts', action: 'read' }, create_routing: { resource: 'alerts', action: 'write' }, update_routing: { resource: 'alerts', action: 'write' }, delete_routing: { resource: 'alerts', action: 'write' }, set_default: { resource: 'alerts', action: 'write' }, create_escalation: { resource: 'alerts', action: 'write' }, update_escalation: { resource: 'alerts', action: 'write' }, delete_escalation: { resource: 'alerts', action: 'write' }, }, manage_notification_channels: { list: { resource: 'alerts', action: 'read' }, test: { resource: 'alerts', action: 'write' }, create: { resource: 'alerts', action: 'write' }, update: { resource: 'alerts', action: 'write' }, delete: { resource: 'alerts', action: 'write' }, }, // Saved filter tools manage_saved_filters: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, }, // CIS hardening tools get_cis_compliance: { resource: 'devices', action: 'read' }, get_cis_device_report: { resource: 'devices', action: 'read' }, apply_cis_remediation: { resource: 'devices', action: 'execute' }, get_huntress_status: { resource: 'devices', action: 'read' }, get_huntress_incidents: { resource: 'devices', action: 'read' }, sync_huntress_data: { resource: 'organizations', action: 'write' }, // User risk scoring tools get_user_risk_scores: { resource: 'users', action: 'read' }, get_user_risk_detail: { resource: 'users', action: 'read' }, assign_security_training: { resource: 'users', action: 'write' }, // M365 helpdesk tools (Delegant-backed). These Graph operations have no // per-operation first-party REST route; the whole M365 surface is gated on // ORGS_READ / ORGS_WRITE at its connection routes (m365.ts:29-30, // m365CustomerGraphRead.ts:47-51, m365CustomerGraphActions.ts:44-48), and // the sibling m365_query_* tools already use those. `m365:*` / `google:*` // were never catalog resources (2026-09-17 audit §2.6). m365_lookup_user: { resource: 'organizations', action: 'read' }, m365_recent_signins: { resource: 'organizations', action: 'read' }, m365_list_group_memberships: { resource: 'organizations', action: 'read' }, m365_disable_user: { resource: 'organizations', action: 'write' }, // Org-wide identity governance, provider-neutral: a plan may hold M365, // Google and device effects at once, so it takes the identity-tenant ceiling. operator_plan: { resource: 'organizations', action: 'write' }, m365_reset_password: { resource: 'organizations', action: 'write' }, // M1 control-plane identity writes: same org-governance ceiling as the pair above. m365_revoke_sessions: { resource: 'organizations', action: 'write' }, m365_remove_license: { resource: 'organizations', action: 'write' }, m365_assign_license: { resource: 'organizations', action: 'write' }, m365_remove_from_group: { resource: 'organizations', action: 'write' }, m365_add_to_group: { resource: 'organizations', action: 'write' }, m365_retire_intune_device: { resource: 'organizations', action: 'write' }, m365_create_user: { resource: 'organizations', action: 'write' }, m365_set_auto_reply: { resource: 'organizations', action: 'write' }, // Google Workspace helpdesk tools (DWD service-account-backed). Same // reasoning as the M365 block above; routes/google.ts:29-30 gates the // Google surface on ORGS_READ / ORGS_WRITE. google_lookup_user: { resource: 'organizations', action: 'read' }, google_reset_password: { resource: 'organizations', action: 'write' }, google_suspend_user: { resource: 'organizations', action: 'write' }, google_restore_user: { resource: 'organizations', action: 'write' }, google_signout: { resource: 'organizations', action: 'write' }, google_set_forwarding: { resource: 'organizations', action: 'write' }, google_disable_forwarding: { resource: 'organizations', action: 'write' }, google_set_vacation: { resource: 'organizations', action: 'write' }, google_update_user: { resource: 'organizations', action: 'write' }, google_share_calendar: { resource: 'organizations', action: 'write' }, google_offboard_user: { resource: 'organizations', action: 'write' }, google_wipe_mobile_device: { resource: 'organizations', action: 'write' }, google_account_wipe_mobile_device: { resource: 'organizations', action: 'write' }, google_security_drift: { resource: 'organizations', action: 'read' }, google_email_report: { resource: 'organizations', action: 'read' }, google_list_user_groups: { resource: 'organizations', action: 'read' }, google_add_to_group: { resource: 'organizations', action: 'write' }, google_remove_from_group: { resource: 'organizations', action: 'write' }, google_move_ou: { resource: 'organizations', action: 'write' }, google_rename_user: { resource: 'organizations', action: 'write' }, google_reset_2sv: { resource: 'organizations', action: 'write' }, google_add_mail_delegate: { resource: 'organizations', action: 'write' }, google_remove_mail_delegate: { resource: 'organizations', action: 'write' }, google_list_licenses: { resource: 'organizations', action: 'read' }, google_assign_license: { resource: 'organizations', action: 'write' }, google_remove_license: { resource: 'organizations', action: 'write' }, // Bootstrap authTools (MCP-OAUTH-11). These dispatch outside the main aiTools // registry (see mcpServer.ts dispatchBootstrapAuthTool) but MUST still carry a // product-RBAC mapping — enforced regardless of MCP_REQUIRE_EXECUTE_ADMIN. A // registry-parity test (aiGuardrails.bootstrapParity.test.ts) fails if a future // bootstrap tool omits an entry here. configure_defaults touches three product // surfaces (device groups, alert rules, notification channels), so its extra // permissions live in TOOL_EXTRA_PERMISSIONS below. send_deployment_invites: { resource: 'devices', action: 'write' }, configure_defaults: { resource: 'organizations', action: 'write' }, // Registration-debt payoff: RBAC entries for tools that were registered in // aiTools but had no TOOL_PERMISSIONS entry (legacyPermissionGaps in // aiToolsRegistryParity.test.ts). See that file's history for context. // Mirrors routes/incidents.ts POST / (requireIncidentWrite = alerts:write, L42/66-71), // not organizations:write (W04 route-binding). create_incident: { resource: 'alerts', action: 'write' }, // Route requires alerts:read (routes/incidents.ts:37, requireIncidentRead), // matching list_incidents above (aiGuardrails.routeBinding.contract.test.ts). get_incident_timeline: { resource: 'alerts', action: 'read' }, generate_incident_report: { resource: 'alerts', action: 'read' }, // Device-execute (analogy: s1_isolate_device, execute_command; collect_evidence includes // screenshot => privileged extraction like take_screenshot) execute_containment: { resource: 'devices', action: 'execute' }, collect_evidence: { resource: 'devices', action: 'execute' }, // Policy-prereq family (analogy: manage_backup_profiles per-action map) manage_update_rings: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, }, // Mirrors routes/backup/configs.ts — reads gate on BACKUP_READ, writes on // BACKUP_WRITE (profiles.ts:91/135/149/208/256; configs.ts:227/246/ // 339/362/504/541). NOT the generic device/policy grants: backup // profile and config writes arm retention and storage credentials. manage_backup_configs: { list: { resource: 'backup', action: 'read' }, get: { resource: 'backup', action: 'read' }, create: { resource: 'backup', action: 'write' }, update: { resource: 'backup', action: 'write' }, }, // Device reads (analogy: analyze_boot_performance, query_change_log) get_user_experience_metrics: { resource: 'devices', action: 'read' }, get_ip_history: { resource: 'devices', action: 'read' }, get_active_users: { resource: 'devices', action: 'read' }, // Security visibility (analogy: get_security_posture devices:read; PAM entries mirror // routes/pam.ts requirePamRead/Execute) get_dns_security: { resource: 'devices', action: 'read' }, manage_dns_policy: { resource: 'devices', action: 'write' }, get_browser_security: { resource: 'devices', action: 'read' }, manage_browser_policy: { list: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, apply: { resource: 'devices', action: 'execute' }, // queues real deviceCommands (parity: apply_cis_remediation) }, get_sensitive_data_overview: { resource: 'devices', action: 'read' }, remediate_sensitive_data: { encrypt: { resource: 'devices', action: 'execute' }, quarantine: { resource: 'devices', action: 'execute' }, secure_delete: { resource: 'devices', action: 'execute' }, // All six actions share POST /sensitive-data/remediate, which gates on // devices:execute (routes/sensitiveData.ts requireSensitiveDataExecute, L43/808-812). accept_risk: { resource: 'devices', action: 'execute' }, false_positive: { resource: 'devices', action: 'execute' }, mark_remediated: { resource: 'devices', action: 'execute' }, }, request_elevation: { resource: 'devices', action: 'execute' }, // requesting is not approving — unchanged (fix/pam-dedicated-permissions); the AI handler inserts a pending request and ignores an auto_approve rule verdict (W04 re-read), so a pam:approve holder still answers it revoke_elevation: { resource: 'pam', action: 'approve' }, // routes/pam.ts revoke gates on requirePamApprove (fix/pam-dedicated-permissions) get_elevation_history: { resource: 'devices', action: 'read' }, // requirePamRead, unchanged // Administrator-approved read-only diagnostic access (aiToolsDiagnosticAccess.ts). // Requesting and using need devices:execute (the same bar as file_operations — // the agent reads as root/LocalSystem); APPROVING is decided per grant by the // approvals flow, which requires devices:execute + approvals:decide on the // device's org and site (services/diagnosticAccess/grants.ts isEligibleApprover). request_diagnostic_access: { resource: 'devices', action: 'execute' }, list_diagnostic_access_grants: { resource: 'devices', action: 'read' }, revoke_diagnostic_access: { resource: 'devices', action: 'read' }, // revoking only removes access; handler limits it to the requester or an eligible approver diagnostic_list_directory: { resource: 'devices', action: 'execute' }, diagnostic_read_file: { resource: 'devices', action: 'execute' }, // Compliance / software / peripheral (analogy: query_compliance_policies policies:read; // manage_configuration_policy map) get_software_compliance: { resource: 'devices', action: 'read' }, manage_software_policies: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, }, manage_software_policy: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, }, remediate_software_violation: { resource: 'devices', action: 'execute' }, // analogy: apply_cis_remediation // Mirrors routes/peripheralControl.ts — reads gate on DEVICES_READ // (L333, 427, 479), writes on ORGS_WRITE (L492, 637, 701). manage_peripheral_policies: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'organizations', action: 'write' }, update: { resource: 'organizations', action: 'write' }, }, // Mirrors routes/peripheralControl.ts — reads gate on DEVICES_READ // (L333, 427, 479), writes on ORGS_WRITE (L492, 637, 701). manage_peripheral_policy: { create: { resource: 'organizations', action: 'write' }, update: { resource: 'organizations', action: 'write' }, disable: { resource: 'organizations', action: 'write' }, add_exception: { resource: 'organizations', action: 'write' }, remove_exception: { resource: 'organizations', action: 'write' }, }, get_peripheral_activity: { resource: 'devices', action: 'read' }, // Network (mirror backing REST routes: networkChanges.ts uses devices:read + alerts:acknowledge; // networkBaselines.ts uses devices:write) get_network_changes: { resource: 'devices', action: 'read' }, list_network_assets: { resource: 'devices', action: 'read' }, get_network_asset: { resource: 'devices', action: 'read' }, get_network_asset_reachability: { resource: 'devices', action: 'read' }, // Topology reads (M3 Task 6): the devices:read floor is enforced by requireTopologySiteAccess. get_interface_history: { resource: 'topology', action: 'read' }, get_link_health: { resource: 'topology', action: 'read' }, get_topology_impact: { resource: 'topology', action: 'read' }, get_recent_network_changes: { resource: 'topology', action: 'read' }, // M3-D12: the devices:read floor comes from requireTopologySiteAccess('read'). get_topology_monitoring_status: { resource: 'topology', action: 'read' }, // M4 Task 1 (#6000): same floor, applied by the M4-D1 site-pinned gate. get_topology: { resource: 'topology', action: 'read' }, get_link_evidence: { resource: 'topology', action: 'read' }, get_diagnostic_run: { resource: 'topology', action: 'read' }, // M4 Task 4: devices:execute here (one pair per tool, catalog-backed, the // network_discovery precedent); the full floor — topology:execute + // devices:execute + both reads, org/site ceilings — is // requireTopologySiteAccess('execute'), applied at proposal AND at release. diagnose_connectivity: { resource: 'devices', action: 'execute' }, // Full-control W08 (#6778) — the topology routes gate on requireTopologySiteCapability (topologyPermissionPairs), // which aiGuardrails.routeBinding cannot parse; aiToolsTopologyOps.permissions.contract.test.ts pins the // capability -> grant mapping instead. The headline pair is here, the rest of the capability floor in the // extras maps (approver eligibility reads requiredPermissionsForTool, so the whole floor must be visible). get_topology_graph: { resource: 'topology', action: 'read' }, run_topology_diagnostic: { collectors: { resource: 'topology', action: 'read' }, get: { resource: 'topology', action: 'read' }, start: { resource: 'topology', action: 'execute' }, cancel: { resource: 'topology', action: 'execute' }, }, manage_topology_manual: { resource: 'topology', action: 'write' }, manage_topology_monitoring: { list_policies: { resource: 'topology', action: 'read' }, list_targets: { resource: 'topology', action: 'read' }, get_settings: { resource: 'topology', action: 'read' }, upsert_policy: { resource: 'topology', action: 'write' }, delete_policy: { resource: 'topology', action: 'write' }, upsert_target: { resource: 'topology', action: 'write' }, delete_target: { resource: 'topology', action: 'write' }, patch_settings: { resource: 'topology', action: 'write' }, }, // Template-library routes authorise in the service: topology + devices read for a read, write for a write. manage_topology_templates: { list_templates: { resource: 'topology', action: 'read' }, list_versions: { resource: 'topology', action: 'read' }, site_options: { resource: 'topology', action: 'read' }, create_template: { resource: 'topology', action: 'write' }, update_template: { resource: 'topology', action: 'write' }, revoke_template: { resource: 'topology', action: 'write' }, create_version: { resource: 'topology', action: 'write' }, publish_version: { resource: 'topology', action: 'write' }, }, apply_topology_template: { preview: { resource: 'topology', action: 'write' }, apply: { resource: 'topology', action: 'write' }, status: { resource: 'topology', action: 'read' }, }, // Full-control W09 (#6779) — each floor is read from its route by the W09 rows of aiGuardrails.routeBinding.contract.test.ts. // SNMP templates / metrics / probe: devices:read|write (routes/snmp.ts, monitoringAssetMetrics.ts, discoveryAssetProbe.ts). // Known guests: organizations:read|write. UniFi: billing:manage for reads AND the sync (routes/unifi/index.ts). manage_snmp_templates: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, browse_oids: { resource: 'devices', action: 'read' }, validate_oids: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, delete: { resource: 'devices', action: 'write' }, }, get_snmp_metrics: { resource: 'devices', action: 'read' }, probe_network_asset: { resource: 'devices', action: 'write' }, manage_known_guests: { list: { resource: 'organizations', action: 'read' }, add: { resource: 'organizations', action: 'write' }, delete: { resource: 'organizations', action: 'write' }, }, get_unifi_status: { resource: 'billing', action: 'manage' }, trigger_unifi_sync: { resource: 'billing', action: 'manage' }, // Full-control W10 (#6780) — each floor is read from its route by the W10 rows of aiGuardrails.routeBinding.contract.test.ts. // Device AI activity: devices:read (routes/devices/aiOrigin.ts). Operator tasks, agent schedules, fleet designs: ai_agents:read. get_device_ai_activity: { resource: 'devices', action: 'read' }, list_operator_tasks: { resource: 'ai_agents', action: 'read' }, get_operator_task: { resource: 'ai_agents', action: 'read' }, list_ai_agent_schedules: { resource: 'ai_agents', action: 'read' }, list_fleet_designs: { resource: 'ai_agents', action: 'read' }, get_fleet_design: { resource: 'ai_agents', action: 'read' }, // Full-control W11 (#6781) — each floor is read from its route by the W11 rows of // aiGuardrails.routeBinding.contract.test.ts (the security read routes behind requireSecurityReadAccess // are devices:read too, routes/security/readAuthorization.ts). get_security_compliance: { resource: 'devices', action: 'read' }, manage_security_recommendations: { list: { resource: 'devices', action: 'read' }, complete: { resource: 'devices', action: 'write' }, dismiss: { resource: 'devices', action: 'write' }, }, manage_access_reviews: { list: { resource: 'users', action: 'read' }, get: { resource: 'users', action: 'read' }, create: { resource: 'users', action: 'write' }, decide_item: { resource: 'users', action: 'write' }, }, manage_audit_baselines: { list: { resource: 'audit', action: 'read' }, compliance: { resource: 'audit', action: 'read' }, device_results: { resource: 'audit', action: 'read' }, create: { resource: 'organizations', action: 'write' }, request_apply: { resource: 'devices', action: 'write' }, }, manage_compliance_policies: { resource: 'devices', action: 'write' }, manage_security_policies: { list: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, }, manage_recovery_keys: { list: { resource: 'devices', action: 'read' }, rotate: { resource: 'devices', action: 'execute' }, collect: { resource: 'devices', action: 'execute' }, }, manage_pam_rules: { list_rules: { resource: 'devices', action: 'read' }, get_config: { resource: 'devices', action: 'read' }, list_signer_groups: { resource: 'devices', action: 'read' }, create_rule: { resource: 'pam', action: 'manage_policy' }, update_rule: { resource: 'pam', action: 'manage_policy' }, delete_rule: { resource: 'pam', action: 'manage_policy' }, set_config: { resource: 'pam', action: 'manage_policy' }, create_signer_group: { resource: 'pam', action: 'manage_policy' }, update_signer_group: { resource: 'pam', action: 'manage_policy' }, delete_signer_group: { resource: 'pam', action: 'manage_policy' }, }, // Full-control W12 (#6782) — each floor is read from its route by the W12 rows of // aiGuardrails.routeBinding.contract.test.ts. manage_software_catalog: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'write' }, update: { resource: 'devices', action: 'write' }, archive: { resource: 'devices', action: 'write' }, add_version: { resource: 'devices', action: 'write' }, update_version: { resource: 'devices', action: 'write' }, promote_version: { resource: 'devices', action: 'write' }, add_install_method: { resource: 'devices', action: 'write' }, update_install_method: { resource: 'devices', action: 'write' }, delete_install_method: { resource: 'devices', action: 'write' }, import_package: { resource: 'devices', action: 'write' }, }, search_packages: { resource: 'devices', action: 'read' }, manage_software_deployments: { list: { resource: 'devices', action: 'read' }, get: { resource: 'devices', action: 'read' }, summary: { resource: 'devices', action: 'read' }, results: { resource: 'devices', action: 'read' }, create: { resource: 'devices', action: 'execute' }, cancel: { resource: 'devices', action: 'execute' }, retry: { resource: 'devices', action: 'execute' }, }, get_device_software: { resource: 'devices', action: 'read' }, manage_device_software: { resource: 'devices', action: 'execute' }, bootstrap_homebrew: { resource: 'devices', action: 'execute' }, manage_software_inventory: { list: { resource: 'devices', action: 'read' }, names: { resource: 'devices', action: 'read' }, devices: { resource: 'devices', action: 'read' }, approve: { resource: 'devices', action: 'write' }, deny: { resource: 'devices', action: 'write' }, clear: { resource: 'devices', action: 'write' }, }, manage_fleet_findings: { resource: 'devices', action: 'write' }, manage_custom_fields: { resource: 'devices', action: 'write' }, acknowledge_network_device: { resource: 'alerts', action: 'acknowledge' }, configure_network_baseline: { resource: 'devices', action: 'write' }, }; export /** * MFA — what substitutes for the routes' `requireMfa()` (2026-09-17 audit §2.5). * * Several routes mirrored here add `requireMfa()` on top of their permission * gate: remote sessions (routes/remote/index.ts:16), device commands * (routes/devices/commands.ts:49), ticket move_org (routes/tickets/moveOrg.ts:23), * group delete (routes/groups.ts:784). This file has NO MFA concept and * deliberately does not invent one — there is no per-tool MFA hook anywhere on * the chat or MCP tool path. * * VERIFIED, and it is not the tier gate: * - CHAT is already MFA-gated at its own entry point. Every tool call rides * `POST /ai/sessions/:id/messages`, which carries `requireMfa()` * (routes/ai.ts:654), as does session creation (:190). `requireMfa` passes * only when the JWT carries `mfa === true` — i.e. the token was minted after * MFA verification — or when 2FA is disabled deployment-wide * (`hasSatisfiedMfa`, middleware/auth.ts:936-939). So a chat tool call is * exactly as MFA-verified as a direct call to the equivalent route, and the * parity these routes ask for is already satisfied. Nothing needs adding to * `create_remote_session` or `manage_tickets.move_org` on this axis. * - MCP is NOT MFA-gated: `routes/mcpServer.ts` authenticates API keys and * contains no `requireMfa` / `hasSatisfiedMfa` call. What stands in there is * the TIER gate — a Tier 3 tool is denied outright over MCP because that * transport has no interactive approval surface (`isMcpApprovalRequired`, * mcpServer.ts:994) — plus per-tier scopes. That is a different control, not * the same one: it proves the key's authority, not possession of a second * factor. Every MFA-carrying route mirrored above maps to a Tier 3 tool, so * the tools are unreachable over MCP rather than reachable without MFA. * * The one place this reasoning had a hole was a tool whose route requires MFA * while the tool sat at Tier 1 — reachable over MCP with no gate of either * kind. That was `registry_operations` read_key/get_value, fixed above (§2.4). * Keep the invariant: a tool mirroring a `requireMfa()` route must be at least * Tier 2, and Tier 3 if the route mutates. */ const TOOL_EXTRA_PERMISSIONS: Record = { // configure_defaults (MCP-OAUTH-11): primary organizations.write in // TOOL_PERMISSIONS, plus these — it also creates a default device group and a // baseline alert policy, so require devices.write AND alerts.write. configure_defaults: [ { resource: 'devices', action: 'write' }, { resource: 'alerts', action: 'write' }, ], restore_snapshot: [{ resource: 'backup', action: 'read' }], restore_as_vm: [{ resource: 'backup', action: 'read' }], instant_boot_vm: [{ resource: 'backup', action: 'read' }], restore_mssql_database: [{ resource: 'backup', action: 'read' }], verify_mssql_backup: [{ resource: 'backup', action: 'read' }], restore_hyperv_vm: [{ resource: 'backup', action: 'read' }], // routes/remote/index.ts:16 applies `requirePermission(REMOTE_ACCESS)` (and // requireMfa()) to the WHOLE remote router with `.use('*', …)`, so every // child route — sessions.ts included — inherits it. The device-side grant // below stays: remote:access alone must not be enough (2026-09-17 audit // §2.5). computer_control rides an established remote session and so // inherits the same gate. create_remote_session: [{ resource: 'remote', action: 'access' }], list_remote_sessions: [{ resource: 'remote', action: 'access' }], computer_control: [{ resource: 'remote', action: 'access' }], // W04-D9/QW6: devices:execute (base) dispatches the containment command, and the // tool also writes incident_actions (moves the incident to contained), which REST // gates on alerts:write (routes/incidentActions.ts POST /:id/contain). execute_containment: [{ resource: 'alerts', action: 'write' }], // Full-control W08 (#6778): every topology capability includes topology:read + devices:read (topologyPermissionPairs). get_topology_graph: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], run_topology_diagnostic: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], manage_topology_manual: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], manage_topology_monitoring: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], manage_topology_templates: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], apply_topology_template: [{ resource: 'topology', action: 'read' }, { resource: 'devices', action: 'read' }], }; /** * Per-ACTION extra permissions, for a multiplexed tool where only ONE action's * route carries a second `requirePermission`. `TOOL_EXTRA_PERMISSIONS` is * whole-tool and would over-gate the other 16 `manage_tickets` actions. * * This belongs in the map rather than in the handler: `requiredPermissionsForTool` * is what approver-eligibility (`intentApprovers`, `decideApprovalRequest`) reads * to decide whether a human may approve an agent's proposal. A check that lives * only in a handler is invisible to that path, so an approver could approve into * an authority they do not hold. */ export const TOOL_ACTION_EXTRA_PERMISSIONS: Record< string, Record > = { manage_tickets: { // routes/tickets/moveOrg.ts:21-23 — TICKETS_WRITE *and* ORGS_WRITE (plus // requireMfa(); see the MFA note above). Moving a ticket between orgs // rewrites tenant ownership of the ticket and every child row, so the // organizations grant is the real authority being exercised. move_org: [{ resource: 'organizations', action: 'write' }], }, manage_quotes: { // create_pay_link's primary grant is invoices:send // (the Stripe-session authority, matching POST /invoices/:id/pay-link); // the handler also reads the quote by id to resolve the converted invoice // (createQuotePayLink → quotePay.ts), so quotes:read is required too. create_pay_link: [{ resource: 'quotes', action: 'read' }], }, // Full-control W08 (#6778): the rest of each write capability's floor (permissionPairs.ts). `execute` adds // devices:execute; `configure` adds devices:write + devices:execute; the template library and application // writes need devices:write. run_topology_diagnostic: { start: [{ resource: 'devices', action: 'execute' }], cancel: [{ resource: 'devices', action: 'execute' }], }, manage_topology_monitoring: { upsert_policy: [{ resource: 'devices', action: 'write' }, { resource: 'devices', action: 'execute' }], delete_policy: [{ resource: 'devices', action: 'write' }, { resource: 'devices', action: 'execute' }], upsert_target: [{ resource: 'devices', action: 'write' }, { resource: 'devices', action: 'execute' }], delete_target: [{ resource: 'devices', action: 'write' }, { resource: 'devices', action: 'execute' }], }, manage_topology_templates: { create_template: [{ resource: 'devices', action: 'write' }], update_template: [{ resource: 'devices', action: 'write' }], revoke_template: [{ resource: 'devices', action: 'write' }], create_version: [{ resource: 'devices', action: 'write' }], publish_version: [{ resource: 'devices', action: 'write' }], }, apply_topology_template: { preview: [{ resource: 'devices', action: 'write' }], apply: [{ resource: 'devices', action: 'write' }], }, manage_invoices: { // Materializing a contract line reads the contract, so the // caller needs contracts:read on top of invoices:write. Per-action so // unrelated invoice edits are not raised to contract-read authority. add_contract_line: [{ resource: 'contracts', action: 'read' }], }, file_operations: { // write/rename put files on the device with the agent's own privileges: // the same grant as script execution. Mirrors requireScriptsExecute on POST // /system-tools/devices/:id/files/upload and /files/move (fileBrowser.ts). write: [{ resource: 'scripts', action: 'execute' }], rename: [{ resource: 'scripts', action: 'execute' }], }, }; // Per-tool rate limits live in services/aiToolRateLimits.ts (a leaf module, so // routes can serve the effective values without importing the tool registry); // re-exported here because this is where every guardrail reader looks for them. export { TOOL_RATE_LIMITS } from './aiToolRateLimits'; interface GuardrailCheckCommon { allowed: boolean; requiresApproval: boolean; /** * Set (true) only for Tier-2 resolutions on the #3130 read-only allowlists * (TIER2_READONLY_ACTIONS / TIER2_READONLY_TOOLS): eligible to auto-execute * — with the Tier-2 audit-ledger row — even under per_step approval mode. */ readOnly?: boolean; /** * #7906: set only on the chat-session read-only `event_logs_query` * resolution — the strictly-parsed payload the call was classified on. The * chat gate pins it on the ToolExecutionContext so dispatch uses exactly * these values, never the raw input. */ pinnedEventLogsQuery?: PinnedEventLogsQuery; reason?: string; description?: string; } /** * Discriminated on `tier` so "tier 3 ⇒ approvalScope is set" is a TYPE * invariant rather than prose. A new tier-3 return path that forgets the field * fails to compile; previously it silently produced an over-strict intent * (four_eyes deadline + digest pinning + a second approver) for an action that * was meant to be a plain supervised click — nothing errored, the feature just * quietly didn't work for that tool. * * `approvalScope?: undefined` on the non-3 arm keeps `check.approvalScope` * readable without narrowing (it widens to `AiApprovalScope | undefined`), so * existing consumers — including intentService.ts's `?? 'four_eyes'` * belt-and-braces default — are unaffected. */ export type GuardrailCheck = | (GuardrailCheckCommon & { tier: Exclude; /** Never set off tier 3 — tier 4 is blocked outright, tiers 1-2 auto-execute. */ approvalScope?: undefined; }) | (GuardrailCheckCommon & { tier: 3; /** * REQUIRED on tier 3. `supervised` — the requester approves their own AI * action; `four_eyes` — a second `approvals:decide` holder must decide. * See docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md. */ approvalScope: AiApprovalScope; }); /** * The read-only formula `checkAgentGuardrails` applies to a resolved * `GuardrailCheck`: tier 1 is always read-only; tier 2 is read-only only on * the #3130 allowlists (an explicit `readOnly: true` from an action-level * table, or the tool being in `TIER2_READONLY_TOOLS`). Extracted so * `agentToolCatalog.ts`'s catalog-operation resolution (which never calls * `checkAgentGuardrails` — it has no run policy to check against) computes * the exact same answer `checkAgentGuardrails` would, instead of a * hand-rolled copy that could drift from it. */ export function isReadOnlyResolution( toolName: string, check: Pick, ): boolean { // Execution plane W04: the one exclusion from "tier 1 implies read-only". // See TIER1_NON_READONLY_TOOLS. if (TIER1_NON_READONLY_TOOLS.has(toolName)) return false; return check.tier === 1 || (check.tier === 2 && (check.readOnly === true || TIER2_READONLY_TOOLS.has(toolName))); } /** * `'act'` (wave 4 Part B): a manifest-matched, rule-equivalent mutation under * a live `mode: 'act'` policy. Distinct from `'allow'` — `'act'` additionally * signals the run-loop pre-hook to revalidate (live policy + guardrail * re-run + device/asset pinning) and reserve a `maxActionsPerRun` slot before * dispatch (actRevalidation.ts, Task 3); `'allow'` never does either. */ export type GuardrailDisposition = 'allow' | 'propose' | 'deny' | 'act'; /** * checkAgentGuardrails' verdict. `allowed` stays false for 'propose' on * purpose: a consumer that only reads `allowed` (every pre-3b consumer) * fails CLOSED rather than executing a proposal. */ export type AgentGuardrailCheck = GuardrailCheck & { disposition: GuardrailDisposition }; /** * The sub-operation discriminator for a tool call, resolved EXACTLY the way * `checkGuardrails` and `checkAgentGuardrails` each used to do inline (two * byte-identical copies, now one). `TOOL_ACTION_INPUT_KEYS` overrides the * default `action` key for a multiplexer keyed on something else * (`execute_command`'s `commandType`, #3088). A non-string value at that key * resolves to `undefined`, not a coerced string — callers fall back to * whatever "no action" means for them (checkGuardrails: the tool's base * tier; checkAgentGuardrails: a hard deny on a multiplexed tool; * policyDecide.ts: no `tool:action` key, so a bare-tool registry lookup). * * Exported so `policyDecide.ts`'s canonical-key derivation reuses this * instead of a third inline copy (wave 5 Part B, #3827). */ export function resolveActionForTool(toolName: string, input: Record): string | undefined { const actionKey = TOOL_ACTION_INPUT_KEYS[toolName] ?? 'action'; const actionValue = input[actionKey]; return typeof actionValue === 'string' ? actionValue : undefined; } /** * The model-actionable `run_script { proposalId }` deny message (#7129). Three * distinct, model-actionable outcomes instead of one catch-all: * - `proposal_not_found` — no such id in the caller's org. Deliberately the * SAME message for "doesn't exist" and "exists in a different org" (see * `ProposalContextDenyReason`) — never leak cross-tenant existence. * - `proposal_review_pending` — the review hasn't finished; tells the model to * poll `get_script_proposal` and retry instead of giving up. * - `proposal_review_failed` — the review ended negatively (static-scan * rejection or a technical review failure); a new proposal is required. * `undefined` (no context supplied at all, e.g. a caller that never loaded * one) falls back to the original generic `proposal_context_missing`. */ function buildProposalDenyReason(denyReason: ProposalContextDenyReason | undefined): string { switch (denyReason) { case 'proposal_not_found': return 'proposal_not_found: no reviewed proposal with that id exists in the caller\'s organization'; case 'proposal_review_pending': return 'proposal_review_pending: the proposal\'s review has not completed yet — call get_script_proposal ' + 'to check its status, then retry run_script once the review is no longer pending'; case 'proposal_review_failed': return 'proposal_review_failed: the proposal\'s review ended negatively (scan rejection or a technical ' + 'review failure) — call get_script_proposal for details; submit a new proposal to retry'; default: return 'proposal_context_missing: run_script with a proposalId requires a reviewed proposal in the caller\'s organization'; } } /** * Check guardrails for a tool invocation. * Returns the effective tier and whether approval is needed. */ export function checkGuardrails( toolName: string, input: Record, context?: GuardrailContext, ): GuardrailCheck { // Tier 4: Blocked if (BLOCKED_TOOLS.has(toolName)) { return { tier: 4, allowed: false, requiresApproval: false, reason: `Tool "${toolName}" is not available` }; } const baseTier = getToolTier(toolName); if (baseTier === undefined) { return { tier: 4, allowed: false, requiresApproval: false, reason: `Unknown tool: ${toolName}` }; } // Fail CLOSED on a proposal-backed run with no loaded context. The scope this // call needs is derived from a persisted review, and a missing context means // the proposal is absent, cross-org, or unreviewed — none of which may run. // Placed after the blocked/unknown denies so those keep their own reasons. if (isProposalRunScript(toolName, input) && !context?.proposal) { return { tier: 4, allowed: false, requiresApproval: false, reason: buildProposalDenyReason(context?.proposalDenyReason), }; } // Check for action-based tier escalation. Non-string values resolve to // undefined, which falls through to the base tier (fail-closed). const action = resolveActionForTool(toolName, input); // Tier 1 downgrade: read-only actions on otherwise-high-tier tools if (action && TIER1_ACTIONS[toolName]?.includes(action)) { return { tier: 1, allowed: true, requiresApproval: false, description: buildApprovalDescription(toolName, action, input) }; } // Input-aware Tier-3 escalation (RMM-QA-176 D9). After the Tier-1 downgrade // so a read action can never be escalated by a stray argument; before // TIER3_ACTIONS and TIER2_ACTIONS, and necessarily before the base-tier // resolution below, so the base tier 2 cannot claim it first. if (isInputAwareTier3(toolName, action, input)) { return { tier: 3, allowed: true, requiresApproval: true, approvalScope: resolveApprovalScope(toolName, action, input, context), description: buildApprovalDescription(toolName, action, input) }; } if (action && TIER3_ACTIONS[toolName]?.includes(action)) { return { tier: 3, allowed: true, requiresApproval: true, approvalScope: resolveApprovalScope(toolName, action, input, context), description: buildApprovalDescription(toolName, action, input) }; } if (action && TIER2_ACTIONS[toolName]?.includes(action)) { return { tier: 2, allowed: true, requiresApproval: false, ...(TIER2_READONLY_ACTIONS[toolName]?.includes(action) ? { readOnly: true } : {}), description: buildApprovalDescription(toolName, action, input) }; } // A stored-data read whose input asks the device for fresh data // (LIVE_READ_INPUT_FLAGS). Tier 2 read-only, like file_operations:list and // manage_services:list: no prompt, but an audit row, and an MCP key needs // ai:write. Without the flag the tool keeps its base tier. if (requestsLiveDeviceRead(toolName, input)) { return { tier: 2, allowed: true, requiresApproval: false, readOnly: true, description: buildApprovalDescription(toolName, action, input) }; } // #7906: chat-only, payload-aware read-only event_logs_query. Placed after // every escalation table (none names event_logs_query) and before the base // tier, which would otherwise resolve execute_command's static Tier 3. if (context?.chatSession === true) { const pinnedEventLogsQuery = classifyChatReadOnlyEventLogsQuery(toolName, input); if (pinnedEventLogsQuery) { return { tier: 2, allowed: true, requiresApproval: false, readOnly: true, pinnedEventLogsQuery, description: buildApprovalDescription(toolName, action, input) }; } } // Use base tier from tool registration. Split by literal tier (rather than // `baseTier >= 3` with a conditional spread) so the tier-3 arm's REQUIRED // approvalScope is enforced by the compiler — see GuardrailCheck. if (baseTier === 3) { return { tier: 3, allowed: true, requiresApproval: true, approvalScope: resolveApprovalScope(toolName, action, input, context), description: buildApprovalDescription(toolName, action, input) }; } if (baseTier === 4) { // Only tier 3 gets a scope — a base-Tier-4 tool has no approval path at // all, not a bigger approval scope. return { tier: 4, allowed: true, requiresApproval: true, description: buildApprovalDescription(toolName, action, input) }; } return { tier: baseTier, allowed: true, requiresApproval: false, ...(baseTier === 2 && TIER2_READONLY_TOOLS.has(toolName) ? { readOnly: true } : {}), description: buildApprovalDescription(toolName, action, input) }; } export interface AgentGuardrailPolicy { /** * Both carried, both enforced here. The resolver computes them, but returning * a snapshot makes enforcement advisory — the gate must be able to deny a * disabled or propose-only agent on its own. */ enabled: boolean; mode: AiAgentMode; toolAllowlist: string[]; protectedResources: AiAgentProtectedResources; /** Resolved from the run device, not from caller-controlled tool input. */ deviceSiteId?: string | null; /** * The run's device id, resolved from the run row (never from tool input). * null = device-less run. A device-less run skips the site gate entirely * (buildAgentAuthContext only pins allowedSiteIds when a device exists), so * mutating tools are denied outright for it — there is no site scope to * bound the blast radius. */ deviceId: string | null; /** * P2-4 (#4191): the intent's resolved TICKET target, populated by the * release path (`agentReleaseAuthority.ts`'s ticket mirror of its own * device-target resolution, `resolveIntentTargetTicket` in * `intentTargetScope.ts`) — never from tool input. Ticket-triage runs are * device-less by construction (one run walks the ticket queue, not a * device fleet), so without this a mutating `manage_tickets` call would * always trip the device-less-mutation deny below. Deliberately NOT a * general "any tool with a ticket" escape hatch: only `manage_tickets` * itself consults it (see the deny below) — no run-profile literal * anywhere in this file, keyed off tool name + scope alone. */ scope?: { ticketId: string }; } const SERVICE_INPUT_KEYS = ['serviceName', 'service', 'name']; const PATH_INPUT_KEYS = [ 'path', 'filePath', 'source', 'destination', 'directory', // Swept from the real tool schemas — each of these carries a filesystem path // and every one of them was unprotected. 'newPath', 'targetPath', 'selectedPaths', 'paths', 'filePaths', 'itemPath', 'quarantineDir', 'scriptPath', 'dest', ]; const REGISTRY_INPUT_KEYS = ['key', 'registryKey', 'keyPath']; const DEVICE_TAG_INPUT_KEYS = ['deviceTag', 'tag', 'tagName']; const DEVICE_TAG_ARRAY_INPUT_KEYS = ['deviceTags', 'tags']; const SITE_INPUT_KEYS = ['siteId', 'site_id', 'targetSiteId']; const SITE_ARRAY_INPUT_KEYS = ['siteIds', 'site_ids']; /** * The OTHER way a tool input names a site: a discriminator key whose value is * `'site'`, paired with an id (or ids) under a generic `target*` key. * * `{ level: 'site', targetId }` (apply_configuration_policy, * preview_configuration_change), `{ targetType: 'site', targetIds: [...] }` * (manage_software_policies, manage_browser_policy) and * `{ target_type: 'site', target_ids: { siteIds: [...] } }` (the peripheral * policy tools) all name a site with no site-shaped KEY anywhere, so the * site-named lookup above sees nothing at all. 2026-09-17 audit §2.8. */ const SITE_TARGET_DISCRIMINATOR_KEYS = ['targetType', 'target_type', 'level']; const SITE_TARGET_ID_KEYS = ['targetId', 'target_id', 'targetIds', 'target_ids']; /** Tools whose real tier depends on an `action` argument. */ function isActionMultiplexedTool(toolName: string): boolean { return Boolean( TIER3_ACTIONS[toolName] ?? TIER2_ACTIONS[toolName] ?? TIER1_ACTIONS[toolName], ); } function describeType(value: unknown): string { if (value === undefined) return 'nothing'; if (value === null) return 'null'; return Array.isArray(value) ? 'an array' : `a ${typeof value}`; } /** * Every string leaf of the input, at any depth, paired with the key it sat * under. A top-level key lookup missed nested parameter objects entirely — * `execute_command { commandType:'file_list', payload:{ path:'C:\\Windows\\...' } }` * dispatches the same agent command as `file_operations` but hid its path from * the protected-resource matcher, with no allowlist entry required. */ function collectStringLeaves( value: unknown, depth = 0, out: Array<{ key: string; value: string }> = [], key = '', ): Array<{ key: string; value: string }> { if (depth > 6 || out.length > 500) return out; if (typeof value === 'string') { out.push({ key, value }); } else if (Array.isArray(value)) { for (const item of value) collectStringLeaves(item, depth + 1, out, key); } else if (value && typeof value === 'object') { for (const [childKey, child] of Object.entries(value as Record)) { collectStringLeaves(child, depth + 1, out, childKey); } } return out; } function leafValuesFor(input: Record, keys: string[]): string[] { const wanted = new Set(keys.map((key) => key.toLowerCase())); return collectStringLeaves(input) .filter((leaf) => wanted.has(leaf.key.toLowerCase())) .map((leaf) => leaf.value); } function inputStrings(input: Record, keys: string[]): string[] { return keys.map((key) => input[key]).filter((value): value is string => typeof value === 'string'); } function inputStringArrays(input: Record, keys: string[]): string[] { return keys.flatMap((key) => { const value = input[key]; return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; }); } function isWindowsStylePath(value: string): boolean { return /^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\'); } function normalizeHierarchy(value: string, separator: '\\' | '/', caseInsensitive: boolean): string { const separatorPattern = separator === '\\' ? /[\\/]+/g : /\/+/g; let normalized = value.trim().replace(separatorPattern, separator); while (normalized.length > 1 && normalized.endsWith(separator)) { normalized = normalized.slice(0, -1); } return caseInsensitive ? normalized.toLowerCase() : normalized; } function normalizePath(value: string, separator: '\\' | '/', caseInsensitive: boolean): string { const normalized = normalizeHierarchy(value, separator, caseInsensitive); const segments: string[] = []; for (const segment of normalized.split(separator)) { if (segment === '.') continue; if (segment === '..' && segments.length > 0) { const previous = segments[segments.length - 1]; if (previous !== undefined && previous !== '' && !previous.endsWith(':') && previous !== '..') { segments.pop(); continue; } } segments.push(segment); } return segments.join(separator) || separator; } function isSameOrDescendant(candidate: string, root: string, separator: '\\' | '/'): boolean { return candidate === root || candidate.startsWith(root === separator ? root : `${root}${separator}`); } function pathIsProtected(candidate: string, protectedPath: string): boolean { const windowsStyle = isWindowsStylePath(candidate) || isWindowsStylePath(protectedPath); const separator = windowsStyle ? '\\' : '/'; return isSameOrDescendant( normalizePath(candidate, separator, windowsStyle), normalizePath(protectedPath, separator, windowsStyle), separator, ); } function registryKeyIsProtected(candidate: string, protectedKey: string): boolean { return isSameOrDescendant( normalizeHierarchy(candidate, '\\', true), normalizeHierarchy(protectedKey, '\\', true), '\\', ); } /** * Protected-resource matcher over EXPLICIT name lists. * * Split out of `touchesProtected` for the AI script lane (#5612 W04): the * agent path derives names from NAMED INPUT FIELDS (`serviceName`, path keys, * registry keys — this module has never inspected script content), while the * lane derives them from the shared scanner's `ScriptScanResult.touchedNames`. * Same comparison semantics, one implementation — the path/registry * hierarchy normalisation is exactly the part that must not be duplicated. * * Stays a pure function with no DB or registry import * (`aiGuardrails.imports.contract.test.ts`). */ export function touchesProtectedNames( names: { services?: readonly string[]; paths?: readonly string[]; registryKeys?: readonly string[]; deviceTags?: readonly string[]; }, protectedResources: AiAgentProtectedResources, ): string | null { for (const serviceName of names.services ?? []) { if (protectedResources.services.some( (protectedService) => protectedService.toLowerCase() === serviceName.toLowerCase(), )) { return `service "${serviceName}" is protected`; } } for (const path of names.paths ?? []) { if (protectedResources.paths.some((protectedPath) => pathIsProtected(path, protectedPath))) { return `path "${path}" is protected`; } } for (const registryKey of names.registryKeys ?? []) { if (protectedResources.registryKeys.some( (protectedKey) => registryKeyIsProtected(registryKey, protectedKey), )) { return `registry key "${registryKey}" is protected`; } } for (const deviceTag of names.deviceTags ?? []) { // Case-insensitive, matching services/paths/registry. 'Production' vs // 'production' passed before. if (protectedResources.deviceTags.some( (protectedTag) => protectedTag.toLowerCase() === deviceTag.toLowerCase(), )) { return `device tag "${deviceTag}" is protected`; } } return null; } function touchesProtected( input: Record, protectedResources: AiAgentProtectedResources, ): string | null { return touchesProtectedNames( { services: leafValuesFor(input, SERVICE_INPUT_KEYS), paths: leafValuesFor(input, PATH_INPUT_KEYS), registryKeys: leafValuesFor(input, REGISTRY_INPUT_KEYS), deviceTags: [ ...leafValuesFor(input, DEVICE_TAG_INPUT_KEYS), ...leafValuesFor(input, DEVICE_TAG_ARRAY_INPUT_KEYS), ], }, protectedResources, ); } function isAgentGuardrailPolicy( policy: AgentGuardrailPolicy | null | undefined, ): policy is AgentGuardrailPolicy { if (!policy || !Array.isArray(policy.toolAllowlist)) return false; if (typeof policy.enabled !== 'boolean') return false; if (policy.deviceId !== null && typeof policy.deviceId !== 'string') return false; if (policy.mode !== 'off' && policy.mode !== 'shadow' && policy.mode !== 'act') return false; if (!policy.toolAllowlist.every((toolName) => typeof toolName === 'string')) return false; const resources = policy.protectedResources; if (!resources || typeof resources !== 'object') return false; return [resources.services, resources.paths, resources.registryKeys, resources.deviceTags] .every((values) => Array.isArray(values) && values.every((value) => typeof value === 'string')); } function siteScopeDenial( input: Record, deviceSiteId: string | null | undefined, ): string | null { const selectedSiteIds: string[] = []; for (const key of SITE_INPUT_KEYS) { if (!(key in input)) continue; const value = input[key]; if (typeof value !== 'string') return `site selector "${key}" is invalid`; selectedSiteIds.push(value); } for (const key of SITE_ARRAY_INPUT_KEYS) { if (!(key in input)) continue; const value = input[key]; if (!Array.isArray(value) || !value.every((siteId) => typeof siteId === 'string')) { return `site selector "${key}" is invalid`; } selectedSiteIds.push(...value); } // Discriminated target shape — see SITE_TARGET_DISCRIMINATOR_KEYS. const siteDiscriminator = SITE_TARGET_DISCRIMINATOR_KEYS.find( (key) => typeof input[key] === 'string' && (input[key] as string).toLowerCase() === 'site', ); if (siteDiscriminator) { let sawTargetKey = false; for (const key of SITE_TARGET_ID_KEYS) { if (!(key in input)) continue; sawTargetKey = true; const value = input[key]; if (typeof value === 'string') { selectedSiteIds.push(value); continue; } if (Array.isArray(value)) { if (!value.every((siteId) => typeof siteId === 'string')) { return `site selector "${key}" is invalid`; } selectedSiteIds.push(...(value as string[])); continue; } // Peripheral tools nest the ids: { target_ids: { siteIds: [...] } }. if (value && typeof value === 'object') { const nested = (value as Record)['siteIds'] ?? (value as Record)['site_ids']; if (nested === undefined) { // A `site`-typed target whose ids we cannot read is not something to // wave through — fail closed rather than silently unscoped. return `site selector "${key}" is invalid`; } if (!Array.isArray(nested) || !nested.every((siteId) => typeof siteId === 'string')) { return `site selector "${key}" is invalid`; } selectedSiteIds.push(...(nested as string[])); continue; } return `site selector "${key}" is invalid`; } // `targetType:'site'` with no id key at all is an incoherent selector for a // device-bound run; deny rather than assume it means "my own site". if (!sawTargetKey) return `site selector "${siteDiscriminator}" names a site with no target id`; } if (selectedSiteIds.length === 0) return null; if (!deviceSiteId) return 'run device site is unavailable'; const outsideSite = selectedSiteIds.find((siteId) => siteId !== deviceSiteId); return outsideSite ? `site "${outsideSite}" is outside the run device site` : null; } /** * A single predicate for "is this NAME (not this call, not this policy) ever * reachable by a headless `ai_agent` principal" (W01 quorum amendment WQ4, * #6755). Both `checkAgentGuardrails` (below) and * `agentToolCatalog.ts`'s `listAgentReachableTools` used to hand-duplicate * this same four-way check; this is the one place it is written now. * Deliberately excludes session-only (M365/Google) exposure: that is a * reachability gate on missing ActiveSession state, not an identity-based * deny, and stays local to `listAgentReachableTools`. */ export function isNeverAgentTool(toolName: string): boolean { return BLOCKED_TOOLS.has(toolName) || isSecretBearingTool(toolName) || AGENT_HUMAN_ONLY_TOOLS.has(toolName) || AGENT_DENIED_READ_TOOLS.has(toolName); } /** Reason text for `isNeverAgentTool`'s deny — kept out of the predicate so the predicate stays a pure boolean the two call sites can share. */ function neverAgentToolDenyReason(toolName: string): string { if (isSecretBearingTool(toolName)) { return `Tool "${toolName}" is secret-bearing and never available to agents`; } if (AGENT_HUMAN_ONLY_TOOLS.has(toolName)) { return `Tool "${toolName}" is human-only and is never available to agents`; } if (AGENT_DENIED_READ_TOOLS.has(toolName)) { return `Tool "${toolName}" reads data never available to agents`; } // BLOCKED_TOOLS — already denied above via base.tier === 4 (checkGuardrails // sets tier 4 exactly and only for BLOCKED_TOOLS); kept so isNeverAgentTool // stays total. return `Tool "${toolName}" is not available to agents`; } /** * Structural guardrails for the ai_agent principal. This path intentionally * never consults user RBAC: an agent has no user role to authorize against. */ export function checkAgentGuardrails( toolName: string, input: Record, policy: AgentGuardrailPolicy | null | undefined, context?: GuardrailContext, /** * W5 PR C (#5220, Decision 9): the LIVE invocation tier of a flow tool * (`flow__`), which has no `TOOL_TIERS` entry. Agents may start * Tier-1 flows only, so only `1` is accepted, and only for a flow tool name * — any other name is classified by `checkGuardrails` as before (an unknown * name stays tier 4 / denied). Every other gate below (env flag, kill * switch, policy, enabled, mode) still applies; the agent path also * requires the allowlist (services/flows/agentFlowTools.ts). */ opts: { resolvedTier?: 1 } = {}, ): AgentGuardrailCheck { // #7906: the chat-session opt-in is never honored for an agent run, even if // a caller hands it in — headless principals keep event_logs_query at Tier 3. const agentContext = context?.chatSession ? { ...context, chatSession: undefined } : context; const base: GuardrailCheck = opts.resolvedTier === 1 && isFlowToolName(toolName) ? { tier: 1, allowed: true, requiresApproval: false, readOnly: true, description: `Run ${toolName}` } : checkGuardrails(toolName, input, agentContext); const deny = (reason: string): AgentGuardrailCheck => ({ ...base, allowed: false, requiresApproval: false, disposition: 'deny', reason }); // envFlag reads process.env at CALL time and shares its normalization with // config/env, so the two readers of this flag cannot disagree (a module-level // const also made the kill switch unstubbable, and therefore untestable). if (!envFlag('BREEZE_AI_AGENTS_ENABLED', false)) { return deny('Autonomous AI agents are disabled'); } // Wave 5A Task 2 (#3827): DB-backed kill switch, ADDITIONAL to the env // flag above, not a replacement for it — the two need not agree, and // either alone denies. `getCachedAiKillStateSnapshot` is a pure sync read // of a module-level cache (see `aiKillState.ts`'s header for the ≤5s // staleness bound and why its default is not-killed); this function stays // synchronous, unable to await a fresh DB read on every dispatch, exactly // like the env-flag check above it. const killState = getCachedAiKillStateSnapshot(); if (killState.killed) { return deny(`Autonomous AI agents are kill-switched (epoch ${killState.epoch})`); } if (!isAgentGuardrailPolicy(policy)) { return deny('AI agent run policy snapshot is missing or invalid'); } if (!base.allowed || base.tier === 4) { return deny(base.reason ?? `Tool "${toolName}" is not available to agents`); } // Unconditional deny, above the allowlist and the multiplexed-action // resolution below: BLOCKED_TOOLS, secret-bearing, AGENT_HUMAN_ONLY_TOOLS // and AGENT_DENIED_READ_TOOLS (W01-D5 / WQ1 / WQ2) all sit here so none of // them can depend on a parseable `action` or on the snapshot omitting the // name. if (isNeverAgentTool(toolName)) { return deny(neverAgentToolDenyReason(toolName)); } // W02-D2 (#6756): single human-only ACTIONS of an otherwise reachable tool // (AGENT_HUMAN_ONLY_ACTIONS). Same placement rule as the whole-tool deny // above: before the site gate, the allowlist and every mode branch, so an // allowlist entry naming the action cannot reach past it. const humanOnlyAction = resolveActionForTool(toolName, input); if (humanOnlyAction !== undefined && AGENT_HUMAN_ONLY_ACTIONS.has(`${toolName}:${humanOnlyAction}`)) { return deny(`Action "${toolName}:${humanOnlyAction}" is human-only and is never available to agents`); } const siteDenial = siteScopeDenial(input, policy.deviceSiteId); if (siteDenial) return deny(`Denied: ${siteDenial}`); // An operator switching the agent off, or holding it at propose-only, must be // enforced HERE. The resolver computes enabled/mode but returning a snapshot // makes that advisory — every caller would have to remember to re-check it. if (policy.enabled === false) return deny('Agent is disabled'); if (policy.mode === 'off') return deny('Agent mode is off'); const actionKey = TOOL_ACTION_INPUT_KEYS[toolName] ?? 'action'; const action = resolveActionForTool(toolName, input); // A non-string action (`action: ['write']`) makes checkGuardrails skip its // TIER3_ACTIONS escalation and fall back to the tool's REGISTERED BASE TIER — // which is 1 for most action-multiplexed tools. Falling back is a DOWNGRADE, // not a floor: it collapsed 15 mutating tools to Tier 1 and skipped the // allowlist entirely. An unresolvable action on a multiplexed tool denies. if (action === undefined && isActionMultiplexedTool(toolName)) { return deny( `Tool "${toolName}" requires a string "${actionKey}"; got ${describeType(input[actionKey])}`, ); } const readOnly = isReadOnlyResolution(toolName, base); // A device-less run has no site scope (buildAgentAuthContext pins // allowedSiteIds only when a device exists), so a mutation from it would be // org-wide. Deny rather than propose: a human approving it could not see // what it is bounded to. // // P2-4 (#4191) exemption: a `manage_tickets` call carrying an explicit // ticket binding (`policy.scope.ticketId`, populated only by the release // path from the intent's own scope — see AgentGuardrailPolicy.scope's doc // comment) satisfies the same "the mutation is bounded to something a // human can see" requirement the device/site scope exists to prove, just // on the ticket axis instead of the device axis. Every other tool, and // every OTHER `manage_tickets` call with no ticket scope, still denies // exactly as before — this is not a blanket device-less carve-out. const ticketScoped = toolName === 'manage_tickets' && !!policy.scope?.ticketId; // Execution plane W04 exemption: a workspace tool's "mutation" is bounded // to the run's own sandbox and its frozen `staged_inputs`, not to a device // — the device-less rule exists to keep an ORG-WIDE mutation from being // proposed, and there is nothing org-wide here (the sandbox is inert and // reachable only by this run). It is still allowlist- and // protected-resource-gated below. const workspaceTool = TIER1_NON_READONLY_TOOLS.has(toolName); // Wisp W07 exemption: a coordinator approval CARRIER (AGENT_COORDINATOR_ // CARRIER_TOOLS — every entry is also HUMAN_ONLY_TOOLS, so no model can // raise one) is minted by deterministic code under a device-less assessment // run. It executes nothing org-wide by itself: its release re-runs every // coordinator gate (grant ceiling, org, budget, per-kind permissions held by // the APPROVER). Exempt from the device-less deny and from the agent's own // mutation allowlist — which an operator would never list it in — and // nothing else: the kill switch, disabled/off, tier-4 and never-agent // denies above still apply, and shadow mode still yields 'propose'. const coordinatorCarrier = AGENT_COORDINATOR_CARRIER_TOOLS.has(toolName); if (!readOnly && policy.deviceId === null && !ticketScoped && !workspaceTool && !coordinatorCarrier) { return deny(`Tool "${toolName}" mutates and the run is not device-bound`); } const allowlisted = coordinatorCarrier || policy.toolAllowlist.includes(toolName) || (action !== undefined && policy.toolAllowlist.includes(`${toolName}:${action}`)); if (!readOnly && !allowlisted) { return deny(`Tool "${toolName}"${action ? `:${action}` : ''} is not in the agent's allowlist`); } const protectedHit = touchesProtected(input, policy.protectedResources); if (protectedHit) return deny(`Denied: ${protectedHit}`); // Execution plane W04: allowlisted + not protected ⇒ a workspace tool // executes. Never `propose` (there is nothing a human could approve — the // sandbox is inert) and never `act` (not in the act manifest). Placed AFTER // every structural deny above and BEFORE the mode branches, so shadow mode // cannot turn `workspace_stage` into a recorded proposal. if (workspaceTool) { return { ...base, allowed: true, requiresApproval: false, disposition: 'allow' }; } // Act mode (wave 4 Part B): a manifest-matched, rule-equivalent mutation // executes (through the normal tool path — the pre/post hooks in // runLoop.ts do the actual revalidate/reserve/verify work); everything // else that mutates records a proposal, exactly like shadow. This branch // sits AFTER the allowlist and protected checks (same placement rule as // shadow below), so both outcomes are only reachable for a call the agent // could legitimately make in the first place — every structural deny above // (kill switch, tier 4, secret-bearing, site scope, disabled/off, device- // less mutation, allowlist, protected resources) is untouched and sits // strictly upstream of this branch, never the reverse. if (policy.mode === 'act' && !readOnly) { const op = resolveActOperation(toolName, input); if (op) { return { ...base, allowed: true, requiresApproval: false, disposition: 'act', reason: `Rule-equivalent operation "${op.key}" — act mode executes with verification`, }; } // Unmatched mutation under act: identical semantics to shadow — propose, // never auto-approve-and-execute. There is no "act mode but not manifest // -matched" execution path; the manifest IS the entire act-eligible surface. return { ...base, allowed: false, requiresApproval: false, disposition: 'propose', reason: `Tool "${toolName}" is not act-eligible; recorded as a proposal`, }; } // Shadow proposes; it never mutates — and this branch now sits AFTER the // allowlist and protected checks so 'propose' is only reachable for a call // the agent could legitimately make. allowed:false is load-bearing (see // AgentGuardrailCheck). if (policy.mode === 'shadow' && !readOnly) { return { ...base, allowed: false, requiresApproval: false, disposition: 'propose', reason: `Tool "${toolName}" mutates; shadow mode records a proposal instead of executing`, }; } return { ...base, disposition: 'allow' }; } /** * Core role-resolution + permission-check primitive shared by checkToolPermission * (tools/call) and any other MCP dispatch path that needs to authorize a single * explicit `{ resource, action }` requirement — e.g. resources/read's * MCP_RESOURCE_PERMISSIONS map (MCP-OAUTH-03). Returns null if allowed, or a * denial reason string if not. * * Preserves the same fail-open short-circuits checkToolPermission has always * had: helper sessions carry a synthetic auth with no roleId, and tool/resource * access for those callers is governed elsewhere (the helper whitelist), not * user RBAC. */ export async function checkPermissionRequirement( auth: AuthContext, requirement: { resource: string; action: string } ): Promise { return checkPermissionRequirements(auth, [requirement]); } /** * Batch variant: resolves the user's permissions ONCE and checks every * requirement against that single resolution, returning the first denial. * Use this when a caller has several requirements for the same auth context * (e.g. a tool's base permission plus TOOL_EXTRA_PERMISSIONS) — calling the * single-requirement form in a loop re-fetches getUserPermissions each time. */ /** The RBAC denial an automation principal (AI agent or flow run) gets. */ function automationPermissionDenial(auth: Pick): string { return isFlowPrincipal(auth) ? 'Flow principals are never granted user permissions' : 'AI agent principals are never granted user permissions'; } export async function checkPermissionRequirements( auth: AuthContext, requirements: Array<{ resource: string; action: string }> ): Promise { // Spec 2026-08-22 §3.2: an agent has no role; this helper's "no token ⇒ // allowed" fallback would fail OPEN for it. Deny before anything else. A // flow run (W04 #5219) has no role either. if (isAutomationPrincipal(auth)) { return automationPermissionDenial(auth); } if (requirements.length === 0) return null; if (!auth.token) { const described = requirements.map((r) => `${r.resource}.${r.action}`).join(', '); console.warn( `[aiGuardrails] checkPermissionRequirements called without auth.token for ${described}` ); return null; } if (auth.token.roleId === null) return null; const userPerms = await getUserPermissions(auth.user.id, { partnerId: auth.partnerId || undefined, orgId: auth.orgId || undefined, }); if (!userPerms) { return 'Insufficient permissions: no role assigned'; } for (const requirement of requirements) { if (!hasPermission(userPerms, requirement.resource, requirement.action)) { return `Insufficient permissions: requires ${requirement.resource}.${requirement.action}`; } } return null; } type ToolPermissionRequirement = { resource: string; action: string }; /** What a LIVE_READ_INPUT_FLAGS request requires in place of the tool's stored-read mapping. */ const LIVE_DEVICE_READ_PERMISSION: ToolPermissionRequirement = { resource: 'devices', action: 'execute' }; type ToolPermissionResolution = | { ok: true; requirements: ToolPermissionRequirement[] } | { ok: false; denial: string }; export function resolveToolPermissionRequirements( toolName: string, input: Record, ): ToolPermissionResolution { const permDef = TOOL_PERMISSIONS[toolName]; if (!permDef) { return { ok: false, denial: `No RBAC permission mapping for tool "${toolName}"` }; } // Resolve the required permission (may be action-dependent) let required: ToolPermissionRequirement; const action = input.action as string | undefined; if (requestsLiveDeviceRead(toolName, input)) { // Live device read (LIVE_READ_INPUT_FLAGS): execute-level, like the REST // route that sends the same command. The base mapping is the stored read. required = LIVE_DEVICE_READ_PERMISSION; } else if ('resource' in permDef && 'action' in permDef) { required = permDef as ToolPermissionRequirement; } else if (action && (permDef as Record)[action]) { required = (permDef as Record)[action]!; } else if (action) { // Unknown action for a mapped tool — deny (fail-closed) // Include redirect hints for tools that have been replaced by policy-based management const redirectHints: Record = { manage_service_monitors: 'Use manage_monitor_definitions to author service/process monitors, then manage_policy_feature_link with featureType "monitors" to attach them to a policy.', }; const hint = redirectHints[toolName]; return { ok: false, denial: `Unknown action "${action}" for tool "${toolName}".${hint ? ` ${hint}` : ''}`, }; } else { // Action-multiplexed tool invoked without an `action` arg — deny (fail-closed). // Each sub-operation has its own RBAC permission; without an action we can't // resolve which one applies, so allowing here would let any caller bypass // per-action checks. Zod schemas require `action` anyway; this is defense in depth. return { ok: false, denial: `Missing required "action" argument for tool "${toolName}"`, }; } const actionExtras = action ? (TOOL_ACTION_EXTRA_PERMISSIONS[toolName]?.[action] ?? []) : []; return { ok: true, requirements: [required, ...(TOOL_EXTRA_PERMISSIONS[toolName] ?? []), ...actionExtras], }; } /** * The RBAC requirements a HUMAN needs for this tool call. Used by wave-3b * approver eligibility (a human approving an agent proposal must hold what * they would need to do it themselves). Returns null when the tool has no * mapping — callers must treat null as "nobody is eligible", mirroring * checkToolPermission's deny. */ export function requiredPermissionsForTool( toolName: string, input: Record, ): Array<{ resource: string; action: string }> | null { const resolution = resolveToolPermissionRequirements(toolName, input); return resolution.ok ? resolution.requirements : null; } /** * Check RBAC permissions for a tool invocation. * Returns null if allowed, or an error message if denied. */ export async function checkToolPermission( toolName: string, input: Record, auth: AuthContext ): Promise { // An agent has no user role, so the token-less fallback below would grant it // EVERY tool. This must stay the first statement: checkToolPermission // short-circuits before it delegates to checkPermissionRequirements, so the // deny there does not cover this path. (aiAgentSdk's tool loop, intent // release revalidation, approvals and the MCP server all enter here.) The // same holds for a flow run (W04 #5219): the runner never calls this, and a // flow must never be granted a tool through it. if (isAutomationPrincipal(auth)) { return automationPermissionDenial(auth); } // Helper sessions use a synthetic auth with no roleId — tool access is // governed by the helper whitelist (helperToolFilter), not user RBAC. if (!auth.token) { console.warn(`[aiGuardrails] checkToolPermission called without auth.token for tool ${toolName}`); return null; } if (auth.token.roleId === null) return null; const resolution = resolveToolPermissionRequirements(toolName, input); if (!resolution.ok) return resolution.denial; // One getUserPermissions resolution covers the base requirement and every // extra permission; denials keep the same first-failure ordering as the // old per-requirement loop. return checkPermissionRequirements(auth, resolution.requirements); } /** Check a tool against an already-fresh permission resolution. */ export function checkToolPermissionForResolvedUser( toolName: string, input: Record, userPerms: import('./permissions').UserPermissions, ): string | null { const resolution = resolveToolPermissionRequirements(toolName, input); if (!resolution.ok) return resolution.denial; for (const requirement of resolution.requirements) { if (!hasPermission(userPerms, requirement.resource, requirement.action)) { return `Insufficient permissions: requires ${requirement.resource}.${requirement.action}`; } } return null; } /** * Check per-tool rate limits. * Returns null if allowed, or an error message if rate limited. */ /** * Enforce the per-tool sliding-window limit for one call. * * #6476: the enforced limit is ceil(TOOL_RATE_LIMITS[tool].limit × m), where m * is the effective `toolRateLimitMultiplier` (1–10, default 1) of `scope.orgId` * — the chat session's org, or the MCP API key's org (partner fallback for a * partner-scope caller). The Redis counter stays keyed per user per tool, * across orgs; the CURRENT call's org decides the ceiling it is judged against. * The multiplier can only raise a limit (see services/aiToolRateLimits.ts). */ export async function checkToolRateLimit( toolName: string, userId: string, scope: ToolRateLimitScope, ): Promise { const config = TOOL_RATE_LIMITS[toolName]; if (!config) return null; // No rate limit for this tool const multiplier = await resolveToolRateLimitMultiplier(scope); const limit = scaleToolRateLimit(config.limit, multiplier); const redis = getRedis(); const key = `ai:tool:${userId}:${toolName}`; const result = await rateLimiter(redis, key, limit, config.windowSeconds); if (!result.allowed) { return `Tool rate limit exceeded for ${toolName}. Try again at ${result.resetAt.toISOString()}`; } return null; } /** Model-chosen free text in a W04 headline, capped at 80 characters (#6758). */ function w04Cap(text: string): string { return text.length > 80 ? `${text.slice(0, 80)}…` : text; } /** A trimmed string, or a finite number coerced to a string — never '', null, undefined, NaN. */ function nonEmptyText(value: unknown): string | null { if (typeof value === 'string' && value.trim()) return value.trim(); if (typeof value === 'number' && Number.isFinite(value)) return String(value); return null; } /** * Match execute_command's service payload selection before display formatting. * A defined name wins even when it cannot produce a named headline; only an * undefined name falls back to serviceName. Formatting is not agent validation. */ function serviceNameFromPayload(payload: Record): string | null { return nonEmptyText(payload.name !== undefined ? payload.name : payload.serviceName); } /** `alice@acme.example` -> `acme.example`. Null for a value with no `@`-domain. */ function emailDomain(value: string): string | null { const at = value.lastIndexOf('@'); return at === -1 || at === value.length - 1 ? null : value.slice(at + 1).toLowerCase(); } /** * Flags a destination address whose domain differs from the workspace * mailbox the Google tool call is acting on. `sourceEmail` (the tool's * `userEmail`/`ownerEmail` argument) is used as a stand-in for the connected * Google workspace's verified/primary domain: `buildApprovalDescription` and * `checkGuardrails` are synchronous and run without any org/connection * context (one caller, `mcpServer.ts`'s pre-execution tier check, has no * loaded session at all), so this cannot look up the real, DB-stored * `google_workspace_connections.customer_domain` for the org without turning * this call chain async — out of scope for a display-string fix. Both sides * of the comparison are therefore caller-suppliable tool arguments, not a * server-verified domain, so the label below is worded as a heuristic rather * than a confirmed-external flag. Display-only: does not block or alter the * call. Domain-locking these tools outright using verified connection data * is a possible future hardening. */ function externalDestinationFlag(sourceEmail: string | null, destEmail: string | null): string { if (!sourceEmail || !destEmail) return ''; const sourceDomain = emailDomain(sourceEmail); const destDomain = emailDomain(destEmail); if (!sourceDomain || !destDomain || sourceDomain === destDomain) return ''; return ' (different domain from source mailbox)'; } /** * #5173: `execute_command`'s headline used to be the raw call signature * ('Execute "kill_process" command on device 74e15ef8...') for every * commandType, mutating or not. These builders read `input.payload` (the * tool's `payload: z.record(z.string(), z.unknown())` — deliberately * unvalidated, so these are read-only extractions for display, never a * validation gate) to produce a call-specific verb phrase for the * commandTypes execute_command's schema actually accepts. A commandType not * in this map, or one whose payload lacks the field its builder needs, * returns null and buildApprovalDescription falls back to the pre-existing * generic "Execute "" command" wording below — so an unrecognised or * sparse call never regresses to something worse than what shipped before. */ const EXECUTE_COMMAND_HEADLINE_BUILDERS: Record) => string | null> = { kill_process: (payload) => { const processName = nonEmptyText(payload.processName); const pid = nonEmptyText(payload.pid); if (processName && pid) return `Kill process "${processName}" (PID ${pid})`; if (processName) return `Kill process "${processName}"`; if (pid) return `Kill process PID ${pid}`; return null; }, start_service: (payload) => { const name = serviceNameFromPayload(payload); return name ? `Start service "${name}"` : null; }, stop_service: (payload) => { const name = serviceNameFromPayload(payload); return name ? `Stop service "${name}"` : null; }, restart_service: (payload) => { const name = serviceNameFromPayload(payload); return name ? `Restart service "${name}"` : null; }, list_services: () => 'List services', list_processes: () => 'List running processes', file_read: (payload) => { const path = nonEmptyText(payload.path); return path ? `Read file "${path}"` : null; }, file_list: (payload) => { const path = nonEmptyText(payload.path); return path ? `List files in "${path}"` : 'List files'; }, event_logs_list: () => 'List event logs', event_logs_query: (payload) => { const logName = nonEmptyText(payload.logName); return logName ? `Query "${logName}" event log` : 'Query event log'; }, }; const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`; function iocSweepScopeLabel(scope: unknown): string { const s = (scope && typeof scope === 'object' ? scope : {}) as Record; const count = (key: string) => (Array.isArray(s[key]) ? (s[key] as unknown[]).length : 0); switch (s.kind) { case 'sites': return plural(count('siteIds'), 'site', 'sites'); case 'groups': return plural(count('groupIds'), 'group', 'groups'); case 'devices': return plural(count('deviceIds'), 'device', 'devices'); case 'orgs': return 'this organization'; default: return 'all reachable devices'; } } /** * #6268 approval headlines for ioc_hunt. Sync and DB-free, so a sweep names * the rule-set COUNT and "this organization" rather than set and org names * (the approval card shows the ids and the org beside it). */ function iocHuntHeadline(action: string | undefined, input: Record): string { const idPrefix = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v.slice(0, 8) : 'unknown'); if (action === IOC_HUNT_RUN_ACTION) { const sets = Array.isArray(input.ruleSetIds) ? input.ruleSetIds.length : 0; const n = input.expectedDeviceCount; const reach = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? `up to ${plural(n, 'device', 'devices')}` : 'an unconfirmed number of devices'; const scan = input.scanType === 'custom' ? `custom scan of ${plural(Array.isArray(input.paths) ? input.paths.length : 0, 'path', 'paths')}` : input.scanType === 'full' ? 'full scan' : 'quick scan'; return `IOC sweep (${scan}): ${plural(sets, 'rule set', 'rule sets')} on ${reach} in this organization (${iocSweepScopeLabel(input.scope)})`; } if (action === 'import_iocs') { const count = Array.isArray(input.iocs) ? input.iocs.length : 0; return `Import ${plural(count, 'IOC', 'IOCs')} into rule set ${idPrefix(input.ruleSetId)}`; } if (action === 'cancel_sweep') return `Cancel IOC sweep ${idPrefix(input.sweepId)}`; return humanizeToolAction('ioc_hunt', action); } /** * Build a human-readable description of what the tool is about to do. */ /** * Where a notification channel's alerts would go, for an approval headline: * the URL HOST only (never the path — webhook paths embed tokens) or a * recipient count. Null when the input carries no recognisable destination. */ function describeChannelDestination(config: unknown): string | null { if (!config || typeof config !== 'object') return null; const c = config as Record; // Type-agnostic on purpose (an `update` carries no `type`): name EVERY // destination the config holds, so a decoy key (a Slack-looking // `webhookUrl` beside the `url` a webhook channel actually posts to) can // never hide the real one. Hosts only — webhook paths and queries carry // tokens. Email recipients by domain, never full address (PR review, #6756). const parts: string[] = []; const hosts = new Set(); let unparseable = false; // The keys the senders post to come first, then any other *Url key. const urlKeys = ['url', 'webhookUrl', ...Object.keys(c).filter((k) => /url$/i.test(k) && k !== 'url' && k !== 'webhookUrl')]; for (const key of urlKeys) { const value = c[key]; if (typeof value !== 'string' || value.length === 0) continue; try { hosts.add(new URL(value).host); } catch { unparseable = true; } } if (hosts.size > 0) parts.push(`host ${[...hosts].join(', ')}`); if (unparseable) parts.push('an unparseable URL'); const recipients = [c.recipients, c.to, c.phoneNumbers] .flatMap((v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : [])) .filter((v): v is string => typeof v === 'string' && v.length > 0); const domains = new Set(); let phones = 0; for (const r of recipients) { const at = r.lastIndexOf('@'); if (at > 0 && at < r.length - 1) domains.add(r.slice(at + 1).toLowerCase()); else phones += 1; } const emailCount = recipients.length - phones; // Every distinct domain, never a truncated list. if (emailCount > 0) parts.push(`${emailCount} recipient(s) at ${[...domains].sort().join(', ')}`); if (phones > 0) parts.push(`${phones} phone number(s)`); if (['routingKey', 'integrationKey'].some((k) => typeof c[k] === 'string' && (c[k] as string).length > 0)) { parts.push('a new routing key (not shown)'); } if (['token', 'user', 'userKey', 'botToken'].some((k) => typeof c[k] === 'string' && (c[k] as string).length > 0)) { parts.push('new credentials (not shown)'); } return parts.length > 0 ? parts.join('; ') : null; } /** * "Wisp wants to run at : " — plus the exact batch for a * bulk reply (w00 A30). `orgName` is the name the chat gate resolved for the * verified target org (#10144); without it the card says "this organization", * never a raw id prefix a person cannot read. */ export function describeSpecialistRequest(input: Record, orgName?: string | null): string { const kind = typeof input.kind === 'string' ? input.kind : ''; const label = isSpecialistKind(kind) ? SPECIALIST_KIND_LABELS[kind] : 'a specialist'; // Tenant-controlled text on a card, a stored intent reason and a push: one // line, bounded. const name = orgName?.replace(/\s+/g, ' ').trim() ?? ''; const org = name ? (name.length > 80 ? `${name.slice(0, 80)}…` : name) : 'this organization'; const reason = nonEmptyText(input.reason); let text = `Wisp wants to run ${label} at ${org}${reason ? `: ${reason}` : ''}`; const params = input.params && typeof input.params === 'object' ? input.params as Record : {}; if (kind === 'bulk_ticket_reply') { const ids = Array.isArray(params.ticketIds) ? params.ticketIds.length : 0; const message = nonEmptyText(params.message); text += ` — sends this message to ${ids} ticket(s)${params.holdTickets === true ? ' and sets them to pending' : ''}`; if (message) text += `: "${message.length > 300 ? `${message.slice(0, 300)}…` : message}"`; } return text; } export function buildApprovalDescription( toolName: string, action: string | undefined, input: Record ): string { const parts: string[] = []; // W03 (#6757): call-specific copy needs its primary id; without one the // humanized label is used (the W02 / #9657 fallback rule, // approvalLabels.contract.test.ts). const w03PrimaryKey = W03_PRIMARY_ID_KEYS[toolName]; if (w03PrimaryKey && !w03Present(input[w03PrimaryKey])) return humanizeToolAction(toolName, action); switch (toolName) { case 'operator_plan': { const n = Array.isArray(input.effects) ? input.effects.length : 0; parts.push(`Approve a ${n}-effect plan for AI Operator task ${String(input.taskId ?? '')}`); break; } // Wisp W07: the specialist approval card and the chat escalation. case 'wisp_specialist_request': case 'launch_specialist': { parts.push(describeSpecialistRequest(input)); break; } case 'execute_command': { const commandType = nonEmptyText(input.commandType); const payload = (input.payload && typeof input.payload === 'object' ? input.payload as Record : {}); const specific = commandType ? EXECUTE_COMMAND_HEADLINE_BUILDERS[commandType]?.(payload) : null; parts.push(specific ?? `Execute "${input.commandType}" command`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; } // Endpoint messaging (#8742 W02). Names severity, reach and acknowledgement // so the approver knows what they approve; like the ticket copy, it never // echoes the message text (that is in the input details below the card). case 'send_endpoint_message': { if (action !== 'send') { parts.push(humanizeToolAction(toolName, action)); break; } const t = (input.targets && typeof input.targets === 'object' ? input.targets : {}) as Record; const count = (k: string) => (Array.isArray(t[k]) ? (t[k] as unknown[]).length : 0); const reach = [ count('deviceIds') ? `${count('deviceIds')} device(s)` : null, count('siteIds') ? `every device in ${count('siteIds')} site(s)` : null, count('deviceGroupIds') ? `every device in ${count('deviceGroupIds')} group(s)` : null, ].filter((x): x is string => x !== null); const scope = reach.length > 0 ? reach.join(' and ') : (typeof t.orgId === 'string' ? 'every device in an organization' : 'no targets'); const severity = typeof input.severity === 'string' ? input.severity : 'info'; parts.push( `Show a ${severity} message to ${scope}${input.requiresAck === true ? ', acknowledgement required' : ''}.` + ' Review the title and body in the details before approving.', ); break; } case 'run_script': parts.push(`Run script ${(input.scriptId as string)?.slice(0, 8) ?? 'unknown'}...`); if (Array.isArray(input.deviceIds)) parts.push(`on ${input.deviceIds.length} device(s)`); break; case 'cancel_script_execution': parts.push(`Stop script execution ${(input.executionId as string)?.slice(0, 8) ?? 'unknown'}...`); break; case 'manage_services': parts.push(`${action?.toUpperCase()} service "${input.serviceName}"`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; // P2-4 (#4191): ticket-triage actions get real copy; every other // manage_tickets action (create/assign/update_status/link_alert/...) // falls through to the SAME generic `${toolName}: ${action}` shape the // top-level default produced before this case existed — no regression // for actions this case doesn't special-case. Deliberately NEVER // includes ticket subject/description/comment content — only ids, // hostnames, and field NAMES (never field VALUES, which is what elides // a categoryId's opaque uuid along with everything else). case 'manage_tickets': { const shortTicketId = typeof input.ticketId === 'string' ? `${input.ticketId.slice(0, 8)}...` : 'unknown'; if (action === 'update_fields') { const fieldNames = input.fields && typeof input.fields === 'object' ? Object.keys(input.fields as Record) : []; parts.push(`Update ticket #${shortTicketId} fields (${fieldNames.join(', ') || 'none'})`); if (fieldNames.some((name) => (TICKET_REQUESTER_FIELD_KEYS as readonly string[]).includes(name))) { parts.push('— changes the requester (future customer emails go to the new requester)'); } } else if (action === 'link_device') { const target = typeof input.hostname === 'string' ? input.hostname : (typeof input.serial === 'string' ? input.serial : 'unknown device'); parts.push(`Link device ${target} to ticket #${shortTicketId}`); } else if (action === 'comment') { // W02 (#6756): the same predicate the tier uses, so the headline can // never call a public reply an internal note. Content is never shown. parts.push(input.isPublic === true ? `Post PUBLIC comment on ticket #${shortTicketId} (customer-visible; emails the requester)` : `Post internal note on ticket #${shortTicketId}`); } else if (action === 'update_status') { const target = typeof input.statusName === 'string' ? `"${input.statusName}"` : String(input.status ?? 'unknown'); parts.push(`Set ticket #${shortTicketId} status to ${target}` + (input.status === 'resolved' ? ' (resolved: emails the requester)' : '') + (typeof input.statusName === 'string' ? ' (custom status; may resolve and email the requester)' : '')); } else if (action === 'draft') { const kindLabel = input.kind === 'resolution_note' ? 'resolution note' : 'reply'; parts.push(`Store AI ${kindLabel} draft on ticket #${shortTicketId}`); } else { parts.push(humanizeToolAction(toolName, action)); } break; } // P2-5 (#4192): the op key ONLY. Never the agent's own text, never a // rationale — this string is rendered in the approval inbox and stored on // the intent, and no model-authored content may reach either. // // The parenthetical is not decoration: `cloneValuesFromEffective` // (`aiAgents/supervisedKeyGrant.ts`) materializes the partner's CURRENT // policy as a per-org `ai_agents` row whenever the org has none — the // COMMON case under partner-wide-first. After that the org follows the // partner only where the merge is tighten-only, so a partner that later // WIDENS (a new tool in the allowlist, a raised limit, a new recipient or // trigger) no longer reaches this org. The grant audit row records // `clonedFromEffective` after the fact; this is the only place the second // approver can be told BEFORE they consent. Unconditional because the // description is built at intent-CREATION time, when whether the org // already has a row is a race against the release — stating the // conditional truth is honest at both moments. case 'manage_ai_agents': if (action === 'authorize_supervised_key') { parts.push( `Authorize the AI agent to run "${String(input.opKey ?? 'unknown')}" without an approval ` + 'for this organization in future runs', '(creates a per-organization agent policy override if this organization does not already have one)', ); } else { parts.push(humanizeToolAction(toolName, action)); } break; case 'ioc_hunt': parts.push(iocHuntHeadline(action, input)); break; case 'security_scan': parts.push(`Security: ${action}`); if (input.threatId) parts.push(`threat ${(input.threatId as string).slice(0, 8)}...`); break; case 'file_operations': // #9853: a permanent delete cannot be restored from the agent trash; the // approver must see that before approving. parts.push(action === 'delete' && input.permanent === true ? `File delete (permanent, cannot be restored): ${input.path}` : `File ${action}: ${input.path}`); break; case 'diagnose_connectivity': parts.push(`Topology ${typeof input.recipe_id === 'string' ? input.recipe_id.replace(/_/g, ' ') : 'connectivity'} diagnostic`); if (typeof input.site_id === 'string') parts.push(`at site ${input.site_id.slice(0, 8)}...`); break; case 'network_discovery': parts.push(`Network discovery scan`); if (input.subnet) parts.push(`on ${input.subnet}`); break; case 'take_screenshot': parts.push('Capture screenshot'); if (input.deviceId) parts.push(`from device ${(input.deviceId as string).slice(0, 8)}...`); break; case 'computer_control': parts.push(`Send input action: ${input.action}`); if (input.x !== undefined && input.y !== undefined) parts.push(`at (${input.x}, ${input.y})`); if (input.text) parts.push(`text: "${(input.text as string).slice(0, 30)}${(input.text as string).length > 30 ? '...' : ''}"`); if (input.key) parts.push(`key: ${input.key}`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; // Fleet tools case 'manage_configuration_policy': if (action === 'create') parts.push(`Create configuration policy "${input.name}"`); else if (action === 'delete') parts.push(`Delete configuration policy ${(input.policyId as string)?.slice(0, 8)}...`); else parts.push(`Config policy ${action}: ${(input.policyId as string)?.slice(0, 8) ?? input.name ?? ''}...`); break; case 'manage_deployments': if (action === 'create') parts.push(`Create deployment "${input.name}" (${input.targetType} target)`); else if (action === 'start') parts.push(`Start deployment ${(input.deploymentId as string)?.slice(0, 8)}...`); else if (action === 'cancel') parts.push(`Cancel deployment ${(input.deploymentId as string)?.slice(0, 8)}...`); else parts.push(`Deployment ${action}: ${(input.deploymentId as string)?.slice(0, 8) ?? ''}...`); break; case 'manage_patches': if (action === 'install') parts.push(`Install ${Array.isArray(input.patchIds) ? input.patchIds.length : 0} patch(es) on ${Array.isArray(input.deviceIds) ? input.deviceIds.length : 0} device(s)`); else if (action === 'scan') parts.push(`Trigger patch scan on ${Array.isArray(input.deviceIds) ? input.deviceIds.length : 0} device(s)`); else if (action === 'rollback') parts.push(`Rollback patch ${(input.patchId as string)?.slice(0, 8)}...`); else if (action === 'setup_auto_approval') parts.push(`Setup auto-approval for ${Array.isArray(input.autoApproveSeverities) ? (input.autoApproveSeverities as string[]).join(', ') : 'critical, important'} patches`); else parts.push(`Patch ${action}: ${(input.patchId as string)?.slice(0, 8) ?? ''}...`); break; case 'manage_groups': if (action === 'create') parts.push(`Create ${input.type ?? 'static'} device group "${input.name}"`); else if (action === 'delete') parts.push(`Delete device group ${(input.groupId as string)?.slice(0, 8)}...`); else parts.push(`Group ${action}: ${(input.groupId as string)?.slice(0, 8) ?? input.name ?? ''}...`); break; case 'manage_maintenance_windows': if (action === 'delete') parts.push(`Delete maintenance window ${(input.windowId as string)?.slice(0, 8)}...`); else parts.push(`Maintenance window ${action}: ${(input.windowId as string)?.slice(0, 8) ?? input.name ?? ''}...`); break; case 'manage_automations': if (action === 'create') parts.push(`Create automation "${input.name}"`); else if (action === 'delete') parts.push(`Delete automation ${(input.automationId as string)?.slice(0, 8)}...`); else if (action === 'run') parts.push(`Manually trigger automation ${(input.automationId as string)?.slice(0, 8)}...`); else parts.push(`Automation ${action}: ${(input.automationId as string)?.slice(0, 8) ?? input.name ?? ''}...`); break; case 'manage_alert_rules': if (action === 'delete_rule') parts.push(`Delete alert rule ${(input.ruleId as string)?.slice(0, 8)}...`); else parts.push(`Alert rule ${action}: ${(input.ruleId as string)?.slice(0, 8) ?? input.name ?? ''}...`); break; case 'manage_service_monitors': if (action === 'add') parts.push(`Add ${input.watchType} monitor "${input.displayName || input.name}"`); else if (action === 'remove') parts.push(`Remove monitor ${(input.watchId as string)?.slice(0, 8)}...`); else parts.push(`Service monitors: ${action}`); break; case 'manage_startup_items': parts.push(`${action?.toUpperCase()} startup item "${input.itemName}"`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); if (input.reason) parts.push(`(${(input.reason as string).slice(0, 50)})`); break; case 'set_agent_log_level': parts.push(`Set log level to ${input.level}`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); if (input.durationMinutes) parts.push(`for ${input.durationMinutes} minutes`); break; case 'capture_agent_pprof': parts.push(`Capture agent ${(input.profile as string) ?? 'all'} pprof profile(s)`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; case 'apply_configuration_policy': parts.push(`Assign config policy ${(input.configPolicyId as string)?.slice(0, 8)}...`); parts.push(`to ${input.level} ${(input.targetId as string)?.slice(0, 8)}...`); break; case 'manage_policy_feature_link': parts.push(`${action?.toUpperCase()} ${String(input.featureType ?? 'feature')} link`); parts.push(`on config policy ${(input.configPolicyId as string)?.slice(0, 8) ?? 'unknown'}...`); // #5511 W02: an `update` need not carry featureType, so without this an // approver sees "UPDATE feature link" for a change that installs HP // software on every HP endpoint the policy reaches. Say what it does. if (warrantyHpCmslRequested(input.inlineSettings)) { parts.push('— enables HP CMSL warranty collection (installs HP software on HP devices)'); } break; case 'remove_configuration_policy_assignment': parts.push(`Remove config policy assignment ${(input.assignmentId as string)?.slice(0, 8)}...`); break; case 'execute_playbook': { parts.push('Execute self-healing playbook'); if (input.playbookId) parts.push(`(playbook ${String(input.playbookId).slice(0, 8)}...)`); if (input.deviceId) parts.push(`on device ${String(input.deviceId).slice(0, 8)}...`); break; } case 'manage_scheduled_tasks': parts.push(`${action?.toUpperCase()} scheduled task "${input.taskName}"`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; case 'manage_processes': if (action === 'kill') { // W04 (#6758): the name is the model's claim — the handler sends the // PID only and never checks it (#4149), so the approver is told so. const processName = nonEmptyText(input.processName); parts.push(`Kill process PID ${input.processId}` + (processName ? ` ("${w04Cap(processName)}" as given — not verified against the PID)` : '')); } else { parts.push('List processes'); } if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; // W04 (#6758): containment, evidence and elevation name what and where. case 'execute_containment': { const actionType = nonEmptyText(input.actionType); if (!nonEmptyText(input.deviceId)) { parts.push(humanizeToolAction(toolName, action)); break; } parts.push(`Containment: ${actionType ? w04Cap(actionType.replace(/_/g, ' ')) : '(unspecified action)'} on device ${String(input.deviceId).slice(0, 8)}...`); break; } case 'collect_evidence': { const types = Array.isArray(input.evidenceTypes) ? input.evidenceTypes.filter((t): t is string => typeof t === 'string') : []; parts.push(`Collect evidence (${types.length ? types.map(w04Cap).join(', ') : 'unspecified'})`); if (types.includes('screenshot')) parts.push('— includes a SCREENSHOT of the device screen'); if (input.deviceId) parts.push(`from device ${String(input.deviceId).slice(0, 8)}...`); break; } case 'request_elevation': if (!nonEmptyText(input.deviceId)) { parts.push(humanizeToolAction(toolName, action)); break; } parts.push(`Request local admin elevation for you on device ${String(input.deviceId).slice(0, 8)}... (a second approver must grant it)`); break; // W02 (#6756): the approver must see WHERE alert data will go — the // destination host or the recipient count, never a URL path or token // (webhook paths carry secrets). case 'manage_notification_channels': { const channelRef = typeof input.channelId === 'string' ? `${input.channelId.slice(0, 8)}...` : 'unknown'; if (action === 'create' || action === 'update') { const verb = action === 'create' ? `Create ${typeof input.type === 'string' ? `${input.type} ` : ''}notification channel` : `Update notification channel ${channelRef}`; // Destination before the model-chosen name, and the name capped, so // free text can never push the destination out of view. const destination = describeChannelDestination(input.config); parts.push(verb); if (destination) parts.push(`→ ${destination}`); if (typeof input.name === 'string') { parts.push(`named "${input.name.length > 80 ? `${input.name.slice(0, 80)}…` : input.name}"`); } } else if (action === 'delete') { parts.push(`Delete notification channel ${channelRef} (linked alert routes go silent; cannot be undone)`); } else { parts.push(humanizeToolAction(toolName, action)); } break; } case 'manage_quotes': { const quoteRef = typeof input.quoteId === 'string' ? `${input.quoteId.slice(0, 8)}...` : 'unknown'; if (action === 'decline') { parts.push(`Record quote ${quoteRef} as DECLINED by the customer` + (typeof input.method === 'string' ? ` (via ${input.method})` : '') + ' — cannot be reopened, only revised'); } else if (action === 'create_pay_link') { parts.push(`Create a Stripe payment link for converted quote ${quoteRef}`); } else if (action === 'send' && typeof input.quoteId === 'string') { parts.push(`Send quote ${quoteRef} to the customer`); } else { parts.push(humanizeToolAction(toolName, action)); } break; } case 'manage_billing_catalog': { const itemRef = typeof input.catalogId === 'string' ? `${input.catalogId.slice(0, 8)}...` : 'unknown'; const orgRef = typeof input.orgId === 'string' ? `${input.orgId.slice(0, 8)}...` : 'unknown'; const price = input.price && typeof input.price === 'object' ? (input.price as Record).unitPrice : undefined; const override = input.override && typeof input.override === 'object' ? input.override as Record : {}; const currency = typeof input.currencyCode === 'string' ? input.currencyCode.toUpperCase() : ''; if (action === 'set_price') { parts.push(`Set the ${currency} price of catalog item ${itemRef} to ${String(price ?? 'unknown')} (contract renewals bill at it)`); } else if (action === 'remove_price') { parts.push(`Remove the ${currency} price of catalog item ${itemRef}`); } else if (action === 'set_org_price') { parts.push(`Set organization ${orgRef} price of catalog item ${itemRef} to ${String(override.unitPrice ?? 'unknown')}` + (typeof override.currencyCode === 'string' ? ` ${override.currencyCode}` : '')); } else if (action === 'remove_org_price') { parts.push(`Remove organization ${orgRef} price override of catalog item ${itemRef}`); } else if (action === 'archive_item') { parts.push(`Archive catalog item ${itemRef}`); } else if (action === 'update_item') { // Price-affecting fields show their new value (contract renewals bill // the live price); other fields are named only (PR review, #6756). const item = input.item && typeof input.item === 'object' ? input.item as Record : {}; const PRICE_FIELDS = ['unitPrice', 'costBasis', 'markupPercent', 'taxable', 'isActive']; const fields = Object.keys(item).map((k) => (PRICE_FIELDS.includes(k) ? `${k} → ${String(item[k])}` : k)); parts.push(`Update catalog item ${itemRef} (${fields.join(', ') || 'no fields'})`); } else if (action === 'set_bundle_components') { parts.push(`Replace the components of bundle ${itemRef} (${Array.isArray(input.components) ? input.components.length : 0} component(s))`); } else { parts.push(humanizeToolAction(toolName, action)); } break; } case 'manage_tags': parts.push(`${action?.toUpperCase()} tags`); if (Array.isArray(input.tags)) parts.push(`[${(input.tags as string[]).join(', ')}]`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; case 'registry_operations': // W04 (#6758): key path and value name capped; the value data is never echoed. parts.push(`Registry ${action?.replace(/_/g, ' ')}: ${w04Cap(nonEmptyText(input.keyPath) ?? '(no key)')}`); if (input.valueName) parts.push(`\\${w04Cap(String(input.valueName))}`); if (input.deviceId) parts.push(`on device ${(input.deviceId as string).slice(0, 8)}...`); break; case 'manage_organizations': if (action === 'create_org') parts.push(`Create organization "${input.name}" (with a default Main Office site)`); else if (action === 'update_org') parts.push(`Update organization ${(input.orgId as string)?.slice(0, 8)}...${input.status ? ` (status → ${input.status})` : ''}`); else if (action === 'create_site') parts.push(`Create site "${input.name}" in organization ${(input.orgId as string)?.slice(0, 8) ?? '(own org)'}...`); else if (action === 'add_contact') { // Review finding (fix round 1): `input.name` had no `??` fallback, so // a phone/mobile-only contact (legal since contacts_identifiable_chk // only needs ONE of name/email/phone/mobile) rendered literally as // `Add contact "undefined"` — the approver saw nothing identifying, // the exact failure spec §5 created this branch to prevent. Falls // back through the same priority order createContact accepts, and // lists every OTHER present identifier alongside it so the approver // sees everything supplied, not just whichever field won the fallback. const acName = typeof input.name === 'string' ? input.name : undefined; const acEmail = typeof input.email === 'string' ? input.email : undefined; const acPhone = typeof input.phone === 'string' ? input.phone : undefined; const acMobile = typeof input.mobile === 'string' ? input.mobile : undefined; const acHeadline = acName ?? acEmail ?? acPhone ?? acMobile ?? 'no identifying info provided'; const acOthers = [ acEmail && acEmail !== acHeadline ? `email: ${acEmail}` : null, acPhone && acPhone !== acHeadline ? `phone: ${acPhone}` : null, acMobile && acMobile !== acHeadline ? `mobile: ${acMobile}` : null, ].filter((part): part is string => part !== null); parts.push( `Add contact "${acHeadline}"${acOthers.length ? ` (${acOthers.join(', ')})` : ''} to organization ${(input.orgId as string)?.slice(0, 8) ?? '(own org)'}...` ); // Review finding (fix round 2): `siteId`, `isPrimary` and an // org-level `billing` role are the add_contact inputs whose effect // reaches beyond inserting a row, and none was shown. `isPrimary: true` // DEMOTES whoever holds the scope's primary slot (and, pinned to a // site, REPLACES sites.contact, a public partner-API DTO). An // org-level `billing` role can make the new contact THE billing // contact — organizations.billing_contact, the invoice/quote recipient. // Without these the approver cannot tell "file a new contact" // (routine, hence supervised) apart from "re-point this customer's // invoices". const acSiteId = typeof input.siteId === 'string' ? input.siteId : undefined; if (acSiteId) parts.push(`on site ${acSiteId.slice(0, 8)}...`); if (input.isPrimary === true) { parts.push( `as PRIMARY contact (replaces the ${acSiteId ? "site's current contact" : "organization's current primary contact"})` ); } if (!acSiteId && Array.isArray(input.roles) && input.roles.includes('billing')) { parts.push("with the Billing role (can become the organization's default invoice recipient)"); } } else parts.push(`Organizations: ${action}`); break; // These four tools grant mail // forwarding/delegation or calendar visibility to an arbitrary external // address. Previously they had no case here, so the always-visible web // headline AND the mobile takeover/push title (buildActionLabel reads // this `description` as its `reason`) both fell through to the bare // tool name with zero destination information. The exact destination // now appears in the headline on every surface, flagged distinctly when // its domain differs from the mailbox being acted on. case 'google_set_forwarding': { const userEmail = nonEmptyText(input.userEmail); const forwardTo = nonEmptyText(input.forwardTo); parts.push( `Forward mail from ${userEmail ?? 'unknown mailbox'} to ${forwardTo ?? 'unknown address'}` + externalDestinationFlag(userEmail, forwardTo) ); break; } case 'google_disable_forwarding': { const userEmail = nonEmptyText(input.userEmail); parts.push(`Disable mail forwarding for ${userEmail ?? 'unknown mailbox'}`); if (input.removeAddress === true) { const forwardTo = nonEmptyText(input.forwardTo); if (forwardTo) { parts.push(`and remove forwarding address ${forwardTo}${externalDestinationFlag(userEmail, forwardTo)}`); } } break; } case 'google_add_mail_delegate': { const userEmail = nonEmptyText(input.userEmail); const delegateEmail = nonEmptyText(input.delegateEmail); parts.push( `Grant ${delegateEmail ?? 'unknown address'} delegate access to ${userEmail ?? 'unknown mailbox'}'s mailbox` + externalDestinationFlag(userEmail, delegateEmail) ); break; } case 'google_share_calendar': { const ownerEmail = nonEmptyText(input.ownerEmail); const shareWithEmail = nonEmptyText(input.shareWithEmail); parts.push( `Share ${ownerEmail ?? 'unknown'}'s calendar with ${shareWithEmail ?? 'unknown address'}` + externalDestinationFlag(ownerEmail, shareWithEmail) ); break; } // W03 (#6757, W03-D8): backup, DR and Hyper-V headlines name the target // and what changes. Selected-path LISTS are never echoed (a count only), // and model-chosen free text is capped at 80 characters. case 'restore_snapshot': { const n = Array.isArray(input.selectedPaths) ? input.selectedPaths.length : 0; const target = nonEmptyText(input.targetPath); parts.push( `Restore snapshot ${w03Id(input.snapshotId)} onto device ${w03Id(input.deviceId)}: ` + (n > 0 ? `${n} selected path(s)` : 'full restore') + (target ? ` into ${w03Cap(target)}` : ' to the original location (overwrites live files)'), ); break; } case 'restore_as_vm': parts.push(input.engine === 'rebuild' ? `Rebuild snapshot ${w03Id(input.snapshotId)} as a disk image on host ${w03Id(input.rebuildHostDeviceId)}` : `Restore snapshot ${w03Id(input.snapshotId)} as a new VM on host ${w03Id(input.targetDeviceId)}`); break; case 'instant_boot_vm': parts.push(`Instant-boot snapshot ${w03Id(input.snapshotId)} as a VM on host ${w03Id(input.targetDeviceId)}`); break; case 'restore_hyperv_vm': parts.push(`Restore Hyper-V snapshot ${w03Id(input.snapshotId)} onto host ${w03Id(input.deviceId)}` + (input.generateNewId === false ? ' reusing the original VM id' : ' as a new VM')); break; case 'restore_mssql_database': { const db = nonEmptyText(input.targetDatabase); parts.push(`Restore SQL snapshot ${w03Id(input.snapshotId)} over database ${db ? `"${w03Cap(db)}"` : '(unnamed)'} on device ${w03Id(input.deviceId)}`); break; } case 'restore_c2c_items': { const n = Array.isArray(input.itemIds) ? input.itemIds.length : 0; parts.push(`Restore ${n} cloud backup item(s)${typeof input.targetConnectionId === 'string' ? ` to connection ${w03Id(input.targetConnectionId)}` : ''}`); break; } case 'execute_dr_plan': parts.push(`Run a ${w03Cap(nonEmptyText(input.executionType) ?? 'DR')} of DR plan ${w03Id(input.planId)} (every group's restore runs on its devices)`); break; case 'manage_hyperv_vm': { const verb = HYPERV_VM_VERBS[action ?? '']; parts.push(verb ? `${verb} VM ${w03Id(input.vmId)}` : `${humanizeToolAction(toolName, action)} on VM ${w03Id(input.vmId)}`); break; } case 'manage_hyperv_checkpoints': { const name = nonEmptyText(input.checkpointName); const verb = HYPERV_CHECKPOINT_VERBS[action ?? '']; parts.push(`${verb ?? humanizeToolAction(toolName, action)} checkpoint${name ? ` "${w03Cap(name)}"` : ''} on VM ${w03Id(input.vmId)}`); break; } case 'configure_vault': { const path = nonEmptyText(input.vaultPath); const details = [ typeof input.vaultType === 'string' ? `${w03Cap(input.vaultType)} vault` : null, path ? `at ${w03Cap(path)}` : (action === 'update' ? 'path unchanged' : null), input.retentionCount !== undefined ? `keep ${w03Cap(String(input.retentionCount))} cop(ies)` : null, typeof input.isActive === 'boolean' ? (input.isActive ? 'active' : 'inactive') : null, ].filter((x): x is string => x !== null); const subject = action === 'create' ? `on device ${w03Id(input.deviceId)}` : `vault ${w03Id(input.vaultId)}`; parts.push(`${humanizeToolAction(toolName, action)} ${subject}${details.length ? `: ${details.join(', ')}` : ''}`); break; } case 'manage_backup_profiles': if (action === 'create' && input.ownerScope === 'partner') { parts.push(`Create a PARTNER-WIDE backup profile${input.name ? ` "${w03Cap(String(input.name))}"` : ''} (applies to every organization)`); } else if (action === 'update' || action === 'delete') { // Ownership is DB state: the headline cannot say whether this one is // partner-wide, so it says that it may be (W03-D1, QW6). parts.push(`${humanizeToolAction(toolName, action)} ${w03Id(input.profileId)} (may be partner-wide; ownership is checked when it runs)`); } else { parts.push(humanizeToolAction(toolName, action)); } break; default: parts.push(humanizeToolAction(toolName, action)); } return parts.join(' '); } /** The input key each W03 headline is built around (W03-D8). */ const W03_PRIMARY_ID_KEYS: Readonly> = { restore_snapshot: 'snapshotId', restore_as_vm: 'snapshotId', instant_boot_vm: 'snapshotId', restore_hyperv_vm: 'snapshotId', restore_mssql_database: 'snapshotId', restore_c2c_items: 'itemIds', execute_dr_plan: 'planId', manage_hyperv_vm: 'vmId', manage_hyperv_checkpoints: 'vmId', }; function w03Present(value: unknown): boolean { return (typeof value === 'string' && value.length > 0) || (Array.isArray(value) && value.length > 0); } const HYPERV_VM_VERBS: Readonly> = { start: 'Start', stop: 'Shut down', force_stop: 'Force-stop (hard power-off)', pause: 'Pause', resume: 'Resume', save: 'Save the state of', }; const HYPERV_CHECKPOINT_VERBS: Readonly> = { create: 'Create', delete: 'Delete (merge away)', apply: 'Apply (discards current VM state since)', }; /** First 8 characters of an id for a headline, or a placeholder. */ function w03Id(value: unknown): string { return typeof value === 'string' && value.length > 0 ? `${value.slice(0, 8)}...` : '(unspecified)'; } /** Model-chosen free text in a headline, capped at 80 characters. */ function w03Cap(text: string): string { return text.length > 80 ? `${text.slice(0, 80)}…` : text; }