cloak-api / copilot /render.py
n1th1sh's picture
Upload 45 files
5a60197 verified
Raw History Blame Contribute Delete
15 kB
"""One plain sentence per draft saying what the body does, plus the policy name.
Many admins read only this sentence, so it describes the body, not the request.
"""
from __future__ import annotations
from .model import Draft
from .platform import catalog
from .platform.context import PlatformContext
ACTION_PHRASE = {'block': 'Block', 'warn': 'Warn about', 'redact': 'Redact', 'log': 'Log',
'alert': 'Alert on', 'remediate': 'Recall', 'delete': 'Delete'}
def _rules(d: Draft, ctx: PlatformContext, ids: list[str]) -> str:
names = []
for rid in ids:
if rid == '$NEW_RULE' and d.ruleDecision.new_rule:
names.append(f'new rule "{d.ruleDecision.new_rule["name"]}"')
elif ctx.rule(rid):
names.append(f'"{ctx.rule(rid).name}"')
return ', '.join(names) if names else 'no rule'
def _what(d: Draft, ctx: PlatformContext) -> str:
"""A short name for the data a draft looks for: "Aadhaar, PAN" or "Payment Card Data"."""
rd = d.ruleDecision
if rd.mode == 'superset' and rd.rule_ids and ctx.rule(rd.rule_ids[-1]):
asked = [e for e in ctx.rule(rd.rule_ids[-1]).entities if e not in rd.extras]
return _list([_short(e) for e in asked], 3)
if rd.new_rule:
return rd.new_rule['name'].replace('Copilot - ', '')
names = [ctx.rule(r).name for r in rd.rule_ids if ctx.rule(r)]
if len(names) > 2:
return 'sensitive data' # a name, not an inventory: the summary lists the rules
return _list(names, 2) if names else 'data'
def _short(entity: str) -> str:
"""'Aadhaar Number' -> 'Aadhaar', 'PAN Card' -> 'PAN', 'Credit Card Number' -> 'Credit Card'."""
label = catalog.entity_label(entity)
return {'PAN Card': 'PAN', 'Card CVV': 'CVV', 'US SSN': 'SSN'}.get(label, label.replace(' Number', ''))
def _list(items: list[str], n: int = 4) -> str:
items = list(items)
if len(items) <= n:
return ', '.join(items)
return ', '.join(items[:n]) + f' and {len(items) - n} more'
def summarize(d: Draft, ctx: PlatformContext) -> tuple[str, str]:
"""(summary sentence, policy name)."""
b = d.policyBody
t = d.policyType
parts: list[str] = []
name_bits: list[str] = []
if t == 'endpoint':
ac, ext = b['agentControl'], b['extensionControl']
hosts = b['enabledSites']
names = {s['host']: s['name'] for s in catalog.ai_sites()}
where = 'all AI sites' if set(hosts) == set(catalog.ai_hosts()) else _list([names.get(h, h) for h in hosts])
if ext['detectionRuleIds']:
parts.append(f'{ACTION_PHRASE[b["action"]]} AI prompts on {where} that match '
f'{_rules(d, ctx, ext["detectionRuleIds"])}')
short_where = 'AI' if where == 'all AI sites' else where
name_bits.append(f'{ACTION_PHRASE[b["action"]].split()[0]} {_what(d, ctx)} in {short_where}')
elif any(i.code == 'NO_DETECTION_RULES' for i in d.issues):
parts.append(f'{ACTION_PHRASE[b["action"]]} AI prompts on {where} (choose the data to look for)')
name_bits.append('AI prompts')
if not b['allowFileUpload']:
parts.append(f'block file uploads to {where}')
name_bits.append('No AI uploads')
if not b['allowCodePaste']:
parts.append(f'block pasting code into {where}')
name_bits.append('No code paste to AI')
switches = {
'blockRemovableStorage': 'block USB storage', 'removableStorageReadOnly': 'make USB storage read-only',
'blockPrint': 'block printing', 'blockBluetooth': 'block Bluetooth file transfer',
'blockMobileSync': 'block phone sync', 'blockScreenCapture': 'block screenshots',
'blockCloudUpload': 'block cloud storage uploads', 'blockPersonalEmail': 'block personal webmail',
'blockFileSharing': 'block file-sharing sites', 'blockWebUpload': 'block web and FTP uploads',
'blockDebugTools': 'block debug and proxy tools', 'watermarkPrints': 'watermark printed pages',
'captureScreenshotOnViolation': 'take a screenshot when a policy is broken',
'dlpScanBeforeTransfer': f'scan files before USB or cloud transfer (level {ac["sensitivityThreshold"]})',
}
for k, phrase in switches.items():
if ac.get(k):
if k == 'blockCloudUpload':
svc = [s for s, f in (('Google Drive', 'blockGoogleDrive'), ('OneDrive', 'blockOneDrive'),
('SharePoint', 'blockSharePoint'), ('Dropbox', 'blockDropbox'),
('Box', 'blockBox'), ('iCloud', 'blockiCloud')) if ac.get(f)]
phrase = f'block uploads to {_list(svc)}' if svc else phrase
if k == 'blockPersonalEmail':
svc = [s for s, f in (('Gmail', 'blockGmail'), ('Outlook.com', 'blockOutlookPersonal'),
('Yahoo Mail', 'blockYahooMail'), ('Proton Mail', 'blockProtonMail'))
if ac.get(f)]
phrase = f'block {_list(svc)}' if svc else phrase
parts.append(phrase)
name_bits.append(phrase[0].upper() + phrase[1:])
if ac['allowedUsbDeviceIds']:
parts.append(f'except USB devices {_list(ac["allowedUsbDeviceIds"])}')
if ac['clipboardMode'] != 'off':
mode = {'audit': 'log copy-paste of personal data',
'everywhere': 'clear the clipboard when it holds personal data',
'targets': f'block pasting personal data into {_list(ac["pasteBlockedSites"] + ac["pasteBlockedApps"])}'
}[ac['clipboardMode']]
parts.append(mode)
name_bits.append('Clipboard protection')
for key, kind in (('webBlockListIds', 'websites'), ('appBlockListIds', 'apps')):
ids = ac[key]
if ids:
names_ = []
for lid in ids:
nl = next((x for x in d.newBlockLists if x['placeholder'] == lid), None)
names_.append(f'"{nl["body"]["name"]}"' if nl else f'"{ctx.block_list(lid).name}"')
parts.append(f'block {kind} on {_list(names_)}')
name_bits.append(f'Block {_list([n.strip(chr(34)).replace("Copilot - Blocked sites: ", "").replace("Copilot - Blocked apps: ", "") for n in names_], 2)}')
if b['activeWebCategories']:
cats = b['activeWebCategories']
parts.append(f'block websites in the {_list(cats)} categor{"y" if len(cats) == 1 else "ies"}')
name_bits.append(f'Block {_list(cats, 2)} sites')
ext_switches = {'enforceIncognito': 'block incognito windows', 'blockGuestMode': 'block guest mode',
'blockOtherBrowsers': 'allow only Chrome, Edge and Brave',
'blockDeveloperMode': 'block browser developer mode', 'blockDevTools': 'block browser developer tools',
'blockDownloads': 'block browser downloads',
'enforceExtensionAllowlist': 'allow only approved browser extensions'}
for k, phrase in ext_switches.items():
if ext.get(k) and ext.get(k) != catalog.default_body('endpoint')['extensionControl'].get(k):
parts.append(phrase)
name_bits.append(phrase[0].upper() + phrase[1:])
if not ext['monitorDownloads']:
parts.append('stop logging downloads')
sr = ac['signInRestriction']
if sr['enabled']:
parts.append(f'allow sign-in only with {_list(sr["allowedDomains"])} accounts')
if sr['strictLogin']:
parts.append('refuse sign-in on websites that are not approved')
name_bits.append('Sign-in restriction')
ide = ac['ideControl']
if ide.get('enabled'):
bits = []
if ide.get('detectionRuleIds'):
bits.append(f'{ide["promptAction"]} prompts to AI coding tools that match '
f'{_rules(d, ctx, ide["detectionRuleIds"])}')
if ide.get('blockedAiTools'):
tools = {t['key']: t['name'] for t in catalog.ide()['tools']}
bits.append(f'block {_list([tools.get(x, x) for x in ide["blockedAiTools"]])}')
if ide.get('providerControl') == 'allowlist':
provs = {p['id']: p['name'] for p in catalog.ide()['providers']}
bits.append(f'allow only {_list([provs.get(x, x) for x in ide.get("allowedProviders", [])], 5)} '
f'in AI coding tools')
if ide.get('mcpControl') == 'denylist':
bits.append('block the named MCP servers')
if ide.get('commandAction') != 'warn':
bits.append(f'{ide["commandAction"]} shell commands run by AI agents')
if ide.get('contextAction') != 'warn':
bits.append(f'{ide["contextAction"]} AI agents reading sensitive files')
if ide.get('enforce') is False:
bits.append('monitor AI coding tools without enforcing')
if ide.get('storePromptContent'):
bits.append('store the text of prompts to AI coding tools')
if ide.get('interceptPrompts'):
bits.append('intercept prompts from AI command-line tools')
if ide.get('inspectResponses'):
bits.append('inspect AI responses')
if ide.get('claudeCodeHook'):
bits.append('use the Claude Code hook')
if ide.get('fileAccessAction', 'off') != 'off':
verb_ = {'close': 'close', 'warn': 'warn about', 'log': 'log'}[ide['fileAccessAction']]
bits.append(f'{verb_} AI tools that open sensitive files ({ide["fileAccessThreshold"].lower()} risk and up)')
if ide.get('inEditorAlerts') is False:
bits.append('turn off in-editor alerts')
if ide.get('discoverAiTools') is False:
bits.append('stop discovering AI tools')
parts += bits or ['turn on AI coding tool monitoring']
name_bits.append('AI coding tools' + (': allow only ' + _list(
[p for p in ide.get('allowedProviders', [])], 3) if ide.get('providerControl') == 'allowlist' else ''))
if b['testMode']:
parts.append('in test mode (nothing is enforced)')
grp = ctx.group(b['groupId']).name if b['groupId'] != 'default' and ctx.group(b['groupId']) else None
scope = f'for {grp}' if grp else 'for everyone'
else:
cfg = b['channels'][t]
label = catalog.TYPE_LABEL[t]
rules = cfg.get('detectionRuleIds') or cfg.get('sharingDetectionRuleIds') or []
if t == 'email':
act = ACTION_PHRASE[cfg['action']]
if rules:
parts.append(f'{act} emails that match {_rules(d, ctx, rules)}')
if cfg.get('allowedDomains'):
parts.append(f'except mail only to {_list(cfg["allowedDomains"])}')
if cfg.get('blockedDomains'):
parts.append(f'alert on every email to {_list(cfg["blockedDomains"])}')
if cfg.get('checkBulkForwarding'):
parts.append(f'flag anyone sending {cfg["bulkForwardThreshold"]}+ emails an hour')
if cfg.get('scheduleRestrict'):
parts.append(f'treat sensitive mail outside {cfg["allowedHoursStart"]}:00-{cfg["allowedHoursEnd"]}:00 UTC '
f'as high risk')
name_bits.append(f'{act.split()[0]} {_what(d, ctx)} in email' if rules else 'Email monitoring')
elif t == 'slack':
act = 'Delete' if cfg['action'] == 'delete' else 'Log'
parts.append(f'{act} Slack messages that match {_rules(d, ctx, rules)}')
name_bits.append(f'{act} {_what(d, ctx)} in Slack')
elif t in ('teams', 'chat'):
parts.append(f'Record {label} messages that match {_rules(d, ctx, rules)}')
name_bits.append(f'Monitor {_what(d, ctx)} in {label}')
elif t in ('drive', 'm365files'):
if rules:
parts.append(f'Record {label} shares of files that match {_rules(d, ctx, rules)}')
else:
parts.append(f'Record risky {label} shares')
if cfg.get('autoRevokePublic'):
parts.append('remove public links')
if cfg.get('autoRevokeExternal'):
parts.append('remove external shares')
if cfg.get('trustedDomains'):
parts.append(f'except shares with {_list(cfg["trustedDomains"])}')
if cfg.get('excludedUsers'):
parts.append(f'except {_list(cfg["excludedUsers"])}')
dspm = cfg.get('dspm') or {}
if dspm.get('scanMode') == 'rules' and dspm.get('detectionRuleIds'):
parts.append(f'scan stored files for {_rules(d, ctx, dspm["detectionRuleIds"])}')
undo = ' + '.join(x for x, on in (('remove public links', cfg.get('autoRevokePublic')),
('remove external shares', cfg.get('autoRevokeExternal'))) if on)
name_bits.append(f'{label}: {_what(d, ctx) if rules else "risky shares"}' + (f' ({undo})' if undo else ''))
else:
if cfg.get('scanMode') == 'rules':
parts.append(f'Scan {label} for {_rules(d, ctx, cfg["detectionRuleIds"])}')
else:
parts.append(f'Scan {label} for any sensitive data (discovery)')
if cfg.get('bulkRepoThreshold') and t == 'github':
parts.append(f'flag anyone cloning {cfg["bulkRepoThreshold"]}+ repositories an hour')
if (cfg.get('autoSync') or {}).get('enabled'):
parts.append(f're-scan changes every {cfg["autoSync"]["intervalMinutes"]} minutes')
name_bits.append(f'Scan {label} for {_what(d, ctx)}' if cfg.get('scanMode') == 'rules'
else f'Scan {label} (discovery)')
if t != 'email' and cfg.get('connectorIds'):
names_ = [c.name for c in ctx.connectors_for(t) if c.id in cfg['connectorIds']]
parts.append(f'in {_list(names_)}')
scope = 'for the whole organisation'
sentence = '; '.join(parts) if parts else 'No settings changed'
sentence = sentence[0].upper() + sentence[1:] + f', {scope}. Saved switched off.'
name = ' + '.join(dict.fromkeys(name_bits)) or catalog.TYPE_LABEL[t]
if t == 'endpoint' and b['groupId'] != 'default' and ctx.group(b['groupId']):
name += f' ({ctx.group(b["groupId"]).name})'
return sentence, _unique_name(name[:80], ctx)
def _unique_name(name: str, ctx: PlatformContext) -> str:
taken = {p.name.lower() for p in ctx.policies} | ctx.policyNames
out, n = name, 2
while out.lower() in taken:
out = f'{name[:75]} ({n})'
n += 1
return out