n1th1sh commited on
Commit
91b8be2
·
verified ·
1 Parent(s): 5a60197

Upload 46 files

Browse files
copilot/lexicon/vocab.yaml CHANGED
@@ -666,6 +666,8 @@ verbs:
666
  remove public links, kill the link, disable the link, auto revoke, auto-revoke, remove external access,
667
  remove external shares, take away access]
668
  ALLOW: [allow, allowed, allows, permit, permitted, let, exempt, whitelist, whitelisted, allowlist, approve,
 
 
669
  approved, trust, trusted, unblock, enable access, it is fine, is fine, are fine, is ok, is okay, are ok,
670
  it is ok, it is okay, fine to use, okay to use, ok to use, can use, may use, can keep using, no problem with]
671
  ENABLE: [enable, enabled, turn on, switch on, activate, use, add, require, enforce, enforced, apply, start,
 
666
  remove public links, kill the link, disable the link, auto revoke, auto-revoke, remove external access,
667
  remove external shares, take away access]
668
  ALLOW: [allow, allowed, allows, permit, permitted, let, exempt, whitelist, whitelisted, allowlist, approve,
669
+ stop blocking, no longer block, dont block anymore, do not block anymore, lift the block on,
670
+ remove the block on, remove the block from, unban, unrestrict, re-enable, reenable, allow again,
671
  approved, trust, trusted, unblock, enable access, it is fine, is fine, are fine, is ok, is okay, are ok,
672
  it is ok, it is okay, fine to use, okay to use, ok to use, can use, may use, can keep using, no problem with]
673
  ENABLE: [enable, enabled, turn on, switch on, activate, use, add, require, enforce, enforced, apply, start,
copilot/model.py CHANGED
@@ -49,6 +49,8 @@ class Draft:
49
  # Set when the draft goes into an existing policy (merge.py): {id, name, active} and the changes.
50
  mergeInto: dict | None = None
51
  mergeOps: list[dict] = field(default_factory=list)
 
 
52
 
53
  @property
54
  def status(self) -> str:
@@ -76,6 +78,8 @@ class Draft:
76
  steps.append({'method': 'POST', 'path': '/api/admin/block-lists', 'body': bl['body'],
77
  'resultId': 'list.id', 'replaces': bl['placeholder']})
78
  if self.policyBody is not None and self.mergeInto:
 
 
79
  # Applied by the dashboard to the policy as it is at save time, then saved with PATCH.
80
  steps.append({'method': 'PATCH', 'path': f'/api/admin/policies/{self.mergeInto["id"]}',
81
  'merge': self.mergeOps})
@@ -92,6 +96,7 @@ class Draft:
92
  'policyBody': self.policyBody,
93
  'saveSteps': self.save_steps(),
94
  'mergeInto': self.mergeInto,
 
95
  'questions': [q.to_json() for q in self.questions],
96
  'issues': [i.to_json() for i in self.issues],
97
  'spans': self.spans,
 
49
  # Set when the draft goes into an existing policy (merge.py): {id, name, active} and the changes.
50
  mergeInto: dict | None = None
51
  mergeOps: list[dict] = field(default_factory=list)
52
+ # Which part of the sentence this draft came from: answers are keyed "<slot>@<requestIndex>".
53
+ requestIndex: int = 0
54
 
55
  @property
56
  def status(self) -> str:
 
78
  steps.append({'method': 'POST', 'path': '/api/admin/block-lists', 'body': bl['body'],
79
  'resultId': 'list.id', 'replaces': bl['placeholder']})
80
  if self.policyBody is not None and self.mergeInto:
81
+ if not self.mergeOps:
82
+ return steps # still waiting for an answer: nothing to save yet
83
  # Applied by the dashboard to the policy as it is at save time, then saved with PATCH.
84
  steps.append({'method': 'PATCH', 'path': f'/api/admin/policies/{self.mergeInto["id"]}',
85
  'merge': self.mergeOps})
 
96
  'policyBody': self.policyBody,
97
  'saveSteps': self.save_steps(),
98
  'mergeInto': self.mergeInto,
99
+ 'requestIndex': self.requestIndex,
100
  'questions': [q.to_json() for q in self.questions],
101
  'issues': [i.to_json() for i in self.issues],
102
  'spans': self.spans,
copilot/pipeline.py CHANGED
@@ -16,7 +16,7 @@ import time
16
  import traceback
17
  import uuid
18
 
19
- from . import check, impact, merge, render
20
  from .adapters import ADAPTERS
21
  from .directives import Directives, extract
22
  from .interpret import Request, interpret
@@ -113,8 +113,9 @@ def _parse(text: object, context: object, answers: dict) -> dict:
113
  messages: list[str] = []
114
  for index, req in enumerate(und.requests):
115
  d = _draft(req, index, ctx, answers, messages, directives, len(und.requests))
116
- if d is not None:
117
- drafts.append(d)
 
118
 
119
  if not drafts:
120
  return {'status': 'not_a_policy', 'intent': 'CREATE',
@@ -122,7 +123,11 @@ def _parse(text: object, context: object, answers: dict) -> dict:
122
  'drafts': [], 'issues': issues, 'examples': examples(), 'understood': understood}
123
 
124
  status = 'ready' if all(d.status == 'ready' for d in drafts) else 'needs_input'
125
- msg = (f'{len(drafts)} policies proposed.' if len(drafts) > 1 else 'Policy proposed.')
 
 
 
 
126
  if status == 'needs_input':
127
  msg += ' Answer the questions before saving.'
128
  return {'status': status, 'intent': 'CREATE', 'message': ' '.join([msg] + messages),
@@ -135,7 +140,7 @@ def _answer(answers: dict, slot: str, index: int):
135
 
136
 
137
  def _draft(req: Request, index: int, ctx, answers: dict, messages: list[str],
138
- directives: Directives | None = None, total: int = 1) -> Draft | None:
139
  directives = directives or Directives()
140
  if req.verb == 'UNSUPPORTED':
141
  can = ['block', 'warn', 'redact', 'alert', 'log'] + [
@@ -180,6 +185,11 @@ def _draft(req: Request, index: int, ctx, answers: dict, messages: list[str],
180
  if req.type not in ADAPTERS:
181
  messages.append(_unavailable([req.type]))
182
  return None
 
 
 
 
 
183
 
184
  # ---- data the admin excluded ("all PII except names") and data chosen in answers
185
  data = req.content.data
 
16
  import traceback
17
  import uuid
18
 
19
+ from . import check, impact, merge, render, unblock
20
  from .adapters import ADAPTERS
21
  from .directives import Directives, extract
22
  from .interpret import Request, interpret
 
113
  messages: list[str] = []
114
  for index, req in enumerate(und.requests):
115
  d = _draft(req, index, ctx, answers, messages, directives, len(und.requests))
116
+ for one in (d if isinstance(d, list) else [d] if d is not None else []):
117
+ one.requestIndex = index
118
+ drafts.append(one)
119
 
120
  if not drafts:
121
  return {'status': 'not_a_policy', 'intent': 'CREATE',
 
123
  'drafts': [], 'issues': issues, 'examples': examples(), 'understood': understood}
124
 
125
  status = 'ready' if all(d.status == 'ready' for d in drafts) else 'needs_input'
126
+ changes = sum(1 for d in drafts if d.mergeInto)
127
+ if changes == len(drafts):
128
+ msg = f'{changes} changes to existing policies proposed.' if changes > 1 else 'Change to an existing policy proposed.'
129
+ else:
130
+ msg = (f'{len(drafts)} policies proposed.' if len(drafts) > 1 else 'Policy proposed.')
131
  if status == 'needs_input':
132
  msg += ' Answer the questions before saving.'
133
  return {'status': status, 'intent': 'CREATE', 'message': ' '.join([msg] + messages),
 
140
 
141
 
142
  def _draft(req: Request, index: int, ctx, answers: dict, messages: list[str],
143
+ directives: Directives | None = None, total: int = 1) -> Draft | list[Draft] | None:
144
  directives = directives or Directives()
145
  if req.verb == 'UNSUPPORTED':
146
  can = ['block', 'warn', 'redact', 'alert', 'log'] + [
 
185
  if req.type not in ADAPTERS:
186
  messages.append(_unavailable([req.type]))
187
  return None
188
+ if unblock.applies(req):
189
+ # "unblock usb for finance": switch it off where it is on, never a new "allow" policy
190
+ found, notes = unblock.build(req, ctx, index, answers)
191
+ messages.extend(notes)
192
+ return found
193
 
194
  # ---- data the admin excluded ("all PII except names") and data chosen in answers
195
  data = req.content.data
copilot/platform/context.py CHANGED
@@ -75,6 +75,13 @@ class PolicySummary:
75
  connectorIds: list[str]
76
  provider: str = '' # email policies: 'google' | 'm365' (one saved before the split is Gmail)
77
  active: bool = True
 
 
 
 
 
 
 
78
 
79
 
80
  @dataclass
@@ -96,6 +103,10 @@ class PlatformContext:
96
  # Web Filtering categories (tenant-defined). Unknown when the caller sent none.
97
  webCategories: list[WebCategory] = field(default_factory=list)
98
  webCategoriesKnown: bool = False
 
 
 
 
99
  # The tenant's own regex detectors, and the built-in ones an admin switched off.
100
  detectors: list[Detector] = field(default_factory=list)
101
  disabledDetectors: set[str] = field(default_factory=set)
@@ -135,6 +146,7 @@ class PlatformContext:
135
 
136
 
137
  _LABEL = re.compile(r'^[A-Z][A-Z0-9_]{1,63}$')
 
138
 
139
 
140
  def _s(v: Any, limit: int = 300) -> str:
@@ -252,7 +264,16 @@ def load_context(raw: Any) -> PlatformContext:
252
  connectorIds=_strs(p.get('connectorIds')),
253
  provider=_email_provider(p) if ptype == 'email' else '',
254
  active=p.get('active') is not False,
 
255
  )
 
 
 
 
 
 
 
 
256
  ctx.allPolicies.append(summary)
257
  if summary.active:
258
  ctx.policies.append(summary)
 
75
  connectorIds: list[str]
76
  provider: str = '' # email policies: 'google' | 'm365' (one saved before the split is Gmail)
77
  active: bool = True
78
+ # Device and browser switches that are on ("agentControl.blockPrint"), for unblock requests.
79
+ controls: set[str] = field(default_factory=set)
80
+ # Sites and apps it blocks by name, and the block lists it uses: for "unblock chatgpt".
81
+ blockedDomains: set[str] = field(default_factory=set)
82
+ blockedApps: set[str] = field(default_factory=set)
83
+ webLists: list[str] = field(default_factory=list)
84
+ appLists: list[str] = field(default_factory=list)
85
 
86
 
87
  @dataclass
 
103
  # Web Filtering categories (tenant-defined). Unknown when the caller sent none.
104
  webCategories: list[WebCategory] = field(default_factory=list)
105
  webCategoriesKnown: bool = False
106
+ # True when the caller sent each policy's switches (PolicySummary.controls).
107
+ controlsKnown: bool = False
108
+ # True when the caller sent what each policy blocks (sites, apps, lists).
109
+ blocksKnown: bool = False
110
  # The tenant's own regex detectors, and the built-in ones an admin switched off.
111
  detectors: list[Detector] = field(default_factory=list)
112
  disabledDetectors: set[str] = field(default_factory=set)
 
146
 
147
 
148
  _LABEL = re.compile(r'^[A-Z][A-Z0-9_]{1,63}$')
149
+ _CONTROL = re.compile(r'^(agentControl|extensionControl)\.[A-Za-z][A-Za-z0-9]{1,63}$')
150
 
151
 
152
  def _s(v: Any, limit: int = 300) -> str:
 
264
  connectorIds=_strs(p.get('connectorIds')),
265
  provider=_email_provider(p) if ptype == 'email' else '',
266
  active=p.get('active') is not False,
267
+ controls={c for c in _strs(p.get('controls'), 200) if _CONTROL.match(c)},
268
  )
269
+ if isinstance(p.get('controls'), list):
270
+ ctx.controlsKnown = True
271
+ if isinstance(p.get('blockedDomains'), list):
272
+ ctx.blocksKnown = True
273
+ summary.blockedDomains = {x.lower() for x in _strs(p.get('blockedDomains'), 500)}
274
+ summary.blockedApps = {x.lower() for x in _strs(p.get('blockedApplications'), 500)}
275
+ summary.webLists = _strs(p.get('webBlockListIds'), 100)
276
+ summary.appLists = _strs(p.get('appBlockListIds'), 100)
277
  ctx.allPolicies.append(summary)
278
  if summary.active:
279
  ctx.policies.append(summary)
copilot/unblock.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unblocking a device or browser control: "unblock usb for finance", "allow printing again".
2
+
3
+ "Allow" needs no NEW policy (nothing is blocked unless a policy blocks it), but it is still a real
4
+ request: switch the control off where it is on. Where it is on decides what can be done:
5
+
6
+ - the group's own policy, or any policy for an "everyone" request: offer to switch it off there
7
+ (a change to that existing policy, through merge.py's save step);
8
+ - an org-wide policy, for a group: device and browser controls from org-wide policies apply to
9
+ every group, and a group policy cannot switch them off. Say which policy, and the two real ways
10
+ out, instead of creating something that would not work;
11
+ - nowhere: say so.
12
+
13
+ Needs the controls each policy has on (PolicySummary.controls). Without them (an older server),
14
+ the copilot says it cannot see them rather than guess.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from .lexicon import load
19
+ from .model import Draft, Question
20
+ from .platform import catalog
21
+ from .platform.context import PlatformContext, PolicySummary
22
+
23
+ MAX_POLICIES = 4
24
+
25
+ # What the admin calls each switch, for the messages.
26
+ LABEL = {
27
+ 'agentControl.blockRemovableStorage': 'USB storage', 'agentControl.removableStorageReadOnly': 'USB read-only',
28
+ 'agentControl.blockPrint': 'printing', 'agentControl.blockBluetooth': 'Bluetooth transfer',
29
+ 'agentControl.blockScreenCapture': 'screenshots', 'agentControl.blockMobileSync': 'phone sync',
30
+ 'agentControl.blockCloudUpload': 'cloud storage uploads', 'agentControl.blockPersonalEmail': 'personal webmail',
31
+ 'agentControl.blockFileSharing': 'file-sharing sites', 'agentControl.blockWebUpload': 'web and FTP uploads',
32
+ 'agentControl.blockDebugTools': 'debug and proxy tools', 'extensionControl.enforceIncognito': 'incognito windows',
33
+ 'extensionControl.blockGuestMode': 'guest mode', 'extensionControl.blockDevTools': 'browser developer tools',
34
+ 'extensionControl.blockDeveloperMode': 'browser developer mode', 'extensionControl.blockDownloads': 'downloads',
35
+ 'extensionControl.blockOtherBrowsers': 'other browsers',
36
+ }
37
+
38
+
39
+ def _label(paths: list[str], name: str) -> str:
40
+ return next((LABEL[p] for p in paths if p in LABEL), name.replace('_', ' '))
41
+
42
+
43
+ def _scope(gid: str, ctx: PlatformContext) -> str:
44
+ if gid == 'default':
45
+ return 'everyone'
46
+ g = ctx.group(gid)
47
+ return g.name if g else gid
48
+
49
+
50
+ HOST_ALIASES = {'chatgpt.com': ['chatgpt.com', 'chat.openai.com']}
51
+
52
+
53
+ def applies(req) -> bool:
54
+ """An "allow X" request about device / browser switches, sites, apps or block lists, with no data."""
55
+ if req.type != 'endpoint' or req.verb not in ('ALLOW', 'ENABLE') or req.only:
56
+ return False
57
+ if not req.content.data.empty():
58
+ return False
59
+ c = req.content
60
+ lx = load()
61
+ return any(lx.controls.get(n, {}).get('polarity') == 'restrict' and _paths(n) for n in c.controls) \
62
+ or bool(c.sites or c.ai_sites or c.apps or c.blists)
63
+
64
+
65
+ def _app(x: str) -> str:
66
+ x = x.lower().strip()
67
+ return x[:-4] if x.endswith('.exe') else x
68
+
69
+
70
+ def _site_hit(host: str, entry: str) -> bool:
71
+ """A web entry blocks the domain and every subdomain (PAC dnsDomainIs)."""
72
+ return host == entry or host.endswith('.' + entry)
73
+
74
+
75
+ def _paths(name: str) -> list[str]:
76
+ spec = load().controls.get(name) or {}
77
+ return [p for p, v in (spec.get('set') or {}).items() if v is True]
78
+
79
+
80
+ def build(req, ctx: PlatformContext, index: int = 0, answers: dict | None = None) -> tuple[list[Draft], list[str]]:
81
+ """Drafts that switch the controls off in the policies that have them on, and/or messages."""
82
+ names = [n for n in req.content.controls if load().controls.get(n, {}).get('polarity') == 'restrict' and _paths(n)]
83
+ gids = [g for g in req.content.groups if ctx.group(g)] or ['default']
84
+ gid = gids[0]
85
+ who = _scope(gid, ctx)
86
+ messages: list[str] = []
87
+ site_drafts, site_msgs = _sites_and_lists(req, ctx, gid, index, answers or {})
88
+ messages += site_msgs
89
+ if not names:
90
+ return _combine(site_drafts), messages
91
+ if not ctx.controlsKnown:
92
+ what = ', '.join(_label(_paths(n), n) for n in names)
93
+ return [], [f'I cannot see which policies block {what}, so I cannot unblock it from here. Open the '
94
+ f'Devices & Browsers policies on the Policies page and switch it off there.']
95
+
96
+ ops_by_policy: dict[str, list[dict]] = {}
97
+ labels_by_policy: dict[str, list[str]] = {}
98
+ for n in names:
99
+ paths = _paths(n)
100
+ label = _label(paths, n)
101
+ on = [p for p in ctx.policies if p.type == 'endpoint' and any(x in p.controls for x in paths)]
102
+ if gid != 'default':
103
+ org = [p for p in on if p.groupId == 'default']
104
+ if org:
105
+ names_ = ', '.join(f'"{p.name}"' for p in org[:3])
106
+ first = org[0].name
107
+ extra = (f', or keep it blocked and allow {who}\'s approved drives by serial number '
108
+ f'("allow usb serial ABC123 in {first}")') if 'agentControl.blockRemovableStorage' in paths else ''
109
+ messages.append(
110
+ f'{label[0].upper() + label[1:]} is blocked for everyone by {names_}. Device and browser controls '
111
+ f'in org-wide policies apply to every group, so a policy for {who} cannot switch it off for {who}. '
112
+ f'To let {who} use it, switch it off in {names_} and block it again in policies for the groups '
113
+ f'that should keep it{extra}.')
114
+ continue
115
+ targets = [p for p in on if p.groupId == gid]
116
+ else:
117
+ targets = on
118
+ if not targets:
119
+ messages.append(f'{label[0].upper() + label[1:]} is not blocked for {who} by any active policy, so there '
120
+ f'is nothing to unblock.')
121
+ continue
122
+ for p in targets[:MAX_POLICIES]:
123
+ ops_by_policy.setdefault(p.id, []).extend(
124
+ {'op': 'set', 'path': x, 'value': False} for x in paths if x in p.controls)
125
+ labels_by_policy.setdefault(p.id, []).append(label)
126
+
127
+ drafts = [_draft(ctx.policy(pid), ops, labels_by_policy[pid], ctx)
128
+ for pid, ops in ops_by_policy.items() if ctx.policy(pid)]
129
+ return _combine(site_drafts + drafts), messages
130
+
131
+
132
+ def _combine(drafts: list[Draft]) -> list[Draft]:
133
+ """One draft per policy, however many things in it are unblocked."""
134
+ out: dict[str, Draft] = {}
135
+ for d in drafts:
136
+ pid = d.mergeInto['id']
137
+ if pid not in out:
138
+ out[pid] = d
139
+ continue
140
+ keep = out[pid]
141
+ keep.mergeOps += [o for o in d.mergeOps if o not in keep.mergeOps]
142
+ what = d.summary.split(': ', 1)[-1].rstrip('.')
143
+ if what not in keep.summary:
144
+ keep.summary = keep.summary.rstrip('.') + '; ' + what + '.'
145
+ for i in d.issues:
146
+ keep.issue(i.code, i.level, i.message, **i.detail)
147
+ for q in d.questions:
148
+ keep.ask(q)
149
+ return list(out.values())
150
+
151
+
152
+ def _sites_and_lists(req, ctx: PlatformContext, gid: str, index: int, answers: dict) -> tuple[list[Draft], list[str]]:
153
+ c = req.content
154
+ if not (c.sites or c.ai_sites or c.apps or c.blists):
155
+ return [], []
156
+ who = _scope(gid, ctx)
157
+ if not ctx.blocksKnown:
158
+ return [], ['I cannot see which policies block these sites or apps, so I cannot unblock them from here. '
159
+ 'Open the Devices & Browsers policies on the Policies page.']
160
+ hosts = list(dict.fromkeys(h for s in c.sites + c.ai_sites for h in HOST_ALIASES.get(s, [s])))
161
+ apps = list(dict.fromkeys(_app(a) for a in c.apps))
162
+ lists = list(dict.fromkeys(c.blists))
163
+ in_scope = [p for p in ctx.policies if p.type == 'endpoint']
164
+ drafts: list[Draft] = []
165
+ messages: list[str] = []
166
+
167
+ def scoped(on: list[PolicySummary], what: str) -> list[PolicySummary] | None:
168
+ if gid == 'default':
169
+ return on
170
+ org = [p for p in on if p.groupId == 'default']
171
+ if org:
172
+ messages.append(f'{what} is blocked for everyone by {", ".join(f"{chr(34)}{p.name}{chr(34)}" for p in org[:3])}. '
173
+ f'Block lists and blocked sites in org-wide policies apply to every group, so a policy for '
174
+ f'{who} cannot unblock it for {who}. Unblock it there, and block it again in policies for the '
175
+ f'groups that should keep it.')
176
+ return None
177
+ return [p for p in on if p.groupId == gid]
178
+
179
+ # Whole lists the admin named: "allow social media again"
180
+ for lid in lists:
181
+ bl = ctx.block_list(lid)
182
+ if not bl:
183
+ continue
184
+ field_ = 'agentControl.webBlockListIds' if bl.kind == 'web' else 'agentControl.appBlockListIds'
185
+ on = [p for p in in_scope if lid in (p.webLists if bl.kind == 'web' else p.appLists)]
186
+ targets = scoped(on, f'"{bl.name}"')
187
+ if targets is None:
188
+ continue
189
+ if not targets:
190
+ messages.append(f'"{bl.name}" is not used by any active policy for {who}, so there is nothing to unblock.')
191
+ for p in targets[:MAX_POLICIES]:
192
+ drafts.append(_change(p, [{'op': 'remove', 'path': field_, 'value': [lid]}],
193
+ f'stop using the block list "{bl.name}"', ctx))
194
+
195
+ # Single sites and apps: "unblock chatgpt", "allow whatsapp"
196
+ for kind, names_ in (('web', hosts), ('app', apps)):
197
+ for name in names_:
198
+ label = name if kind == 'web' else f'{name}.exe'
199
+ direct: dict[str, list[str]] = {}
200
+ via: dict[str, list[str]] = {}
201
+ for p in in_scope:
202
+ own = [e for e in (p.blockedDomains if kind == 'web' else p.blockedApps)
203
+ if (_site_hit(name, e) if kind == 'web' else _app(e) == name)]
204
+ if own:
205
+ direct[p.id] = own
206
+ for lid in (p.webLists if kind == 'web' else p.appLists):
207
+ bl = ctx.block_list(lid)
208
+ if bl and bl.kind == kind and any(_site_hit(name, e) if kind == 'web' else _app(e) == name
209
+ for e in bl.entries):
210
+ via.setdefault(p.id, []).append(lid)
211
+ on = [p for p in in_scope if p.id in direct or p.id in via]
212
+ targets = scoped(on, label)
213
+ if targets is None:
214
+ continue
215
+ if not targets:
216
+ msg = f'{label} is not blocked for {who} by any active policy, so there is nothing to unblock.'
217
+ if kind == 'web' and name in catalog.ai_hosts():
218
+ # The site itself is reachable; what users meet is probably a prompt rule.
219
+ msg += (' If prompts to it are being blocked, that comes from an AI prompt rule: the event on '
220
+ 'the Activity page names the policy and rule.')
221
+ messages.append(msg)
222
+ continue
223
+ for p in targets[:MAX_POLICIES]:
224
+ ops: list[dict] = []
225
+ if p.id in direct:
226
+ path = 'agentControl.blockedDomains' if kind == 'web' else 'agentControl.blockedApplications'
227
+ ops.append({'op': 'remove', 'path': path, 'value': direct[p.id]})
228
+ d = _change(p, ops, f'stop blocking {label}', ctx) if ops else None
229
+ for lid in via.get(p.id, []):
230
+ bl = ctx.block_list(lid)
231
+ if not bl.builtin:
232
+ messages.append(f'{label} is in your block list "{bl.name}", used by "{p.name}". Remove it '
233
+ f'from that list on the Block lists page (every policy using the list changes).')
234
+ continue
235
+ others = len(bl.entries) - 1
236
+ field_ = 'agentControl.webBlockListIds' if kind == 'web' else 'agentControl.appBlockListIds'
237
+ dd = d or _change(p, [], f'stop blocking {label}', ctx)
238
+ slot = f'detach_{lid}'
239
+ if answers.get(f'{slot}@{index}', answers.get(slot)) == 'yes':
240
+ dd.mergeOps.append({'op': 'remove', 'path': field_, 'value': [lid]})
241
+ else:
242
+ dd.issue('UNBLOCK_NEEDS_LIST', 'blocking',
243
+ f'{label} is blocked by the built-in list "{bl.name}" in "{p.name}". A built-in list '
244
+ f'cannot be edited, so unblocking it means removing the whole list from the policy, '
245
+ f'which also unblocks its {others} other entries.', list=bl.name, policy=p.name)
246
+ dd.ask(Question(slot, f'Remove "{bl.name}" from "{p.name}"? This unblocks all {others + 1} '
247
+ f'of its entries.',
248
+ [{'value': 'yes', 'label': f'Yes, remove "{bl.name}"'},
249
+ {'value': 'cancel', 'label': 'Cancel'}], True, None))
250
+ d = dd
251
+ if d is not None:
252
+ drafts.append(d)
253
+ return drafts, messages
254
+
255
+
256
+ def _change(p: PolicySummary, ops: list[dict], what: str, ctx: PlatformContext) -> Draft:
257
+ body = catalog.default_body('endpoint')
258
+ body['groupId'] = p.groupId
259
+ body['name'] = p.name
260
+ d = Draft('endpoint', body)
261
+ d.mergeInto = {'id': p.id, 'name': p.name, 'active': p.active}
262
+ d.mergeOps = list(ops)
263
+ d.summary = f'In "{p.name}" ({_scope(p.groupId, ctx)}): {what}.'
264
+ d.issue('MERGE_INTO', 'info', f'This changes the existing policy "{p.name}"; no new policy is created.',
265
+ policy=p.name)
266
+ if p.groupId == 'default':
267
+ d.issue('UNBLOCK_EVERYONE', 'warning', f'"{p.name}" applies to everyone, so this is unblocked for every '
268
+ f'group that has no other policy blocking it.', policy=p.name)
269
+ if p.active:
270
+ d.issue('MERGE_LIVE', 'warning', f'"{p.name}" is on, so the change applies as soon as you save.',
271
+ policy=p.name)
272
+ return d
273
+
274
+
275
+ def _draft(p: PolicySummary, ops: list[dict], labels: list[str], ctx: PlatformContext) -> Draft:
276
+ body = catalog.default_body('endpoint')
277
+ body['groupId'] = p.groupId
278
+ body['name'] = p.name
279
+ d = Draft('endpoint', body)
280
+ d.mergeInto = {'id': p.id, 'name': p.name, 'active': p.active}
281
+ d.mergeOps = ops
282
+ what = ', '.join(dict.fromkeys(labels))
283
+ d.summary = f'In "{p.name}" ({_scope(p.groupId, ctx)}): stop blocking {what}.'
284
+ d.issue('MERGE_INTO', 'info', f'This changes the existing policy "{p.name}"; no new policy is created.',
285
+ policy=p.name)
286
+ if p.groupId == 'default':
287
+ d.issue('UNBLOCK_EVERYONE', 'warning', f'"{p.name}" applies to everyone, so {what} is unblocked for every '
288
+ f'group that has no other policy blocking it.', policy=p.name)
289
+ if p.active:
290
+ d.issue('MERGE_LIVE', 'warning', f'"{p.name}" is on, so the change applies as soon as you save.',
291
+ policy=p.name)
292
+ return d