Download copilot/adapters/base.py from Wardline/cloak-api: direct link, hf CLI and curl.
- Browser
- Download file 15.3 kB
-
https://huggingface.co/spaces/Wardline/cloak-api/resolve/main/copilot/adapters/base.py
- Command line
-
hf download hf://spaces/Wardline/cloak-api/copilot/adapters/base.py
-
curl -L -o base.py https://huggingface.co/spaces/Wardline/cloak-api/resolve/main/copilot/adapters/base.py
15.3 kB
| """Shared adapter helpers: actions, rule questions, groups, connectors, exceptions, thresholds. | |
| An adapter turns one Request into the exact body that type's dashboard form would POST. | |
| It starts from the form's defaults and only changes what the request decided, so a policy | |
| made by the copilot is indistinguishable from one made by hand. | |
| """ | |
| from __future__ import annotations | |
| import difflib | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| from ..interpret import Content, Request | |
| from ..model import Draft, Question | |
| from ..platform import catalog | |
| from ..platform.context import PlatformContext | |
| from ..rules import RuleDecision | |
| VERB_WORD = {'BLOCK': 'block', 'WARN': 'warn', 'REDACT': 'redact', 'ALERT': 'alert', 'LOG': 'log/monitor', | |
| 'DELETE': 'delete', 'REMEDIATE': 'recall', 'REVOKE': 'revoke', 'READONLY': 'make read-only', | |
| 'ALLOW': 'allow', 'ENABLE': 'turn on', 'DISABLE': 'turn off'} | |
| class Build: | |
| """What an adapter hands back.""" | |
| draft: Draft | |
| consumed_exceptions: list[int] = field(default_factory=list) # indexes into request.exceptions | |
| cancelled: bool = False | |
| def answer_for(answers: dict, slot: str, index: int) -> Any: | |
| """An answer for this draft ('slot@index') or for all drafts ('slot').""" | |
| if not isinstance(answers, dict): | |
| return None | |
| v = answers.get(f'{slot}@{index}') | |
| return v if v is not None else answers.get(slot) | |
| def choose_action(d: Draft, req: Request, index: int, answers: dict, *, mapping: dict[str, str], | |
| allowed: list[tuple[str, str]], default: str, type_label: str, | |
| impossible_default: str | None = None) -> str: | |
| """Pick the body action for the request's verb. | |
| mapping: verb -> action for verbs this type can honour. | |
| allowed: [(action, label)] the type accepts, for questions. | |
| A verb that is not in mapping is impossible on this type: the draft keeps a valid action | |
| and a blocking question asks which of the possible ones the admin wants. | |
| """ | |
| valid = [a for a, _ in allowed] | |
| ans = answer_for(answers, 'action', index) | |
| if isinstance(ans, str) and ans in valid: | |
| return ans | |
| verb = req.verb | |
| options = [{'value': a, 'label': lbl} for a, lbl in allowed] | |
| if verb is None or verb in ('ENABLE', 'CREATE', 'DISABLE'): | |
| d.ask(Question('action', f'What should happen when this matches on {type_label}?', options, True, default)) | |
| return default | |
| if req.verb_conf < 0.5: | |
| d.ask(Question('action', f'I could not tell whether you want to block or allow this. ' | |
| f'What should happen on {type_label}?', options, True, default)) | |
| return default | |
| if verb in mapping: | |
| return mapping[verb] | |
| fallback = impossible_default or default | |
| d.issue('ACTION_NOT_AVAILABLE', 'blocking', | |
| f'{type_label} policies cannot {VERB_WORD.get(verb, verb.lower())}. ' | |
| f'They can: {", ".join(lbl for _, lbl in allowed)}.', | |
| requested=verb.lower(), type=d.policyType, available=valid) | |
| d.ask(Question('action', f'{type_label} cannot {VERB_WORD.get(verb, verb.lower())}. Which of these do you want?', | |
| options, True, fallback)) | |
| return fallback | |
| def rule_questions(d: Draft, rd: RuleDecision, ctx: PlatformContext) -> None: | |
| """Questions about the detection-rule choice (never blocking unless the data was guessed).""" | |
| if rd.mode == 'superset' and rd.rule_ids: | |
| r = ctx.rule(rd.rule_ids[-1]) | |
| extra = ', '.join(catalog.entity_label(e) for e in rd.extras) | |
| d.ask(Question('rule', f'Use the existing rule "{r.name if r else rd.rule_ids[-1]}" (it also detects {extra}), ' | |
| f'or create a new rule with only what you asked for?', | |
| [{'value': rd.rule_ids[-1], 'label': f'Use "{r.name if r else rd.rule_ids[-1]}"'}, | |
| {'value': 'create', 'label': 'Create a new rule'}], False, rd.rule_ids[-1])) | |
| if rd.mode == 'create' and rd.alternative and rd.alternative.get('mode') == 'reuse': | |
| alt = rd.alternative | |
| d.ask(Question('rule', f'I will create a new detection rule. The built-in rule "{alt["ruleName"]}" ' | |
| f'already covers this and more. Use it instead?', | |
| [{'value': 'create', 'label': 'Create the new rule'}, | |
| {'value': alt['ruleIds'], 'label': f'Use "{alt["ruleName"]}"'}], False, 'create')) | |
| if rd.assumed: | |
| names = [ctx.rule(r).name for r in rd.rule_ids if ctx.rule(r)] | |
| d.issue('DATA_ASSUMED', 'warning', | |
| f'"Sensitive data" can mean many things. I used these rules: {", ".join(names)}. ' | |
| f'Change the list if you meant something narrower.', ruleIds=list(rd.rule_ids)) | |
| d.ask(Question('data', 'Which data do you want this policy to look for?', | |
| [{'value': r.id, 'label': r.name} for r in ctx.rules], False, list(rd.rule_ids))) | |
| def need_data(d: Draft, rd: RuleDecision, ctx: PlatformContext, why: str) -> None: | |
| d.issue('NO_DETECTION_RULES', 'blocking', why) | |
| d.ask(Question('data', 'What data should this policy look for?', | |
| [{'value': r.id, 'label': r.name} for r in ctx.rules], True, [])) | |
| def resolve_group(d: Draft, req: Request, ctx: PlatformContext, index: int, answers: dict) -> str: | |
| """The groupId for an endpoint policy. An unknown group is asked about, never dropped silently.""" | |
| ans = answer_for(answers, 'group', index) | |
| if isinstance(ans, str) and (ans == 'default' or ctx.group(ans)): | |
| return ans | |
| if req.group_unknown and req.group == 'default': | |
| names = [g.name for g in ctx.groups] | |
| close = difflib.get_close_matches(req.group_unknown, [n.lower() for n in names], n=3, cutoff=0.5) | |
| opts = [{'value': g.id, 'label': g.name} for g in ctx.groups if g.name.lower() in close] | |
| opts += [{'value': g.id, 'label': g.name} for g in ctx.groups if g.name.lower() not in close][:8] | |
| opts.append({'value': 'default', 'label': 'Everyone (org-wide)'}) | |
| d.issue('UNKNOWN_GROUP', 'blocking', f'There is no group called "{req.group_unknown}".', | |
| name=req.group_unknown) | |
| d.ask(Question('group', f'There is no group called "{req.group_unknown}". Which group did you mean?', | |
| opts, True, 'default')) | |
| return 'default' | |
| if req.group != 'default' and ctx.group(req.group): | |
| return req.group | |
| return 'default' | |
| def channel_group_check(d: Draft, req: Request, ctx: PlatformContext, index: int, answers: dict) -> None: | |
| """Channel policies are org-wide in the dashboard: a group can't narrow them.""" | |
| asked = req.group != 'default' or req.group_unknown | |
| if not asked or answer_for(answers, 'scope', index) == 'org': | |
| return | |
| name = ctx.group(req.group).name if ctx.group(req.group) else req.group_unknown | |
| d.issue('GROUP_NOT_SUPPORTED', 'blocking', | |
| f'{catalog.TYPE_LABEL[d.policyType]} policies apply to the whole organisation; they cannot be limited ' | |
| f'to "{name}". Only endpoint policies are scoped to a group.', group=name) | |
| d.ask(Question('scope', f'This policy will apply to everyone, not only {name}. Continue?', | |
| [{'value': 'org', 'label': 'Yes, apply to everyone'}, {'value': 'cancel', 'label': 'Cancel'}], | |
| True, 'org')) | |
| def pick_connectors(d: Draft, req: Request, ctx: PlatformContext, channel: str, index: int, answers: dict) -> list[str]: | |
| ans = answer_for(answers, 'connector', index) | |
| known = {c.id for c in ctx.connectors_for(channel)} | |
| if isinstance(ans, list): | |
| return [a for a in ans if a in known] | |
| if isinstance(ans, str) and ans in known: | |
| return [ans] | |
| ids = [cid for ch, cid in req.content.connectors if ch == channel and cid in known] | |
| if ctx.connectorsKnown and not ctx.connectors_for(channel): | |
| d.issue('NO_CONNECTOR', 'warning', | |
| f'No {catalog.TYPE_LABEL[channel]} connection is set up yet, so this policy will not see anything ' | |
| f'until one is added in Settings.', channel=channel) | |
| return ids | |
| def thresholds_unsupported(d: Draft, req: Request, index: int, answers: dict, handled: set[str] = frozenset()) -> None: | |
| rest = [(n, noun) for n, noun in req.content.thresholds if noun not in handled] | |
| if not rest or answer_for(answers, 'threshold', index) == 'ignore': | |
| return | |
| n, noun = rest[0] | |
| d.issue('THRESHOLD_NOT_SUPPORTED', 'blocking', | |
| f'{catalog.TYPE_LABEL[d.policyType]} policies cannot count ("more than {n} {noun}"). ' | |
| f'The policy acts on the first match.', count=n, noun=noun) | |
| d.ask(Question('threshold', f'The policy will act on the first match, not after {n} {noun}. Continue?', | |
| [{'value': 'ignore', 'label': 'Yes, act on every match'}, {'value': 'cancel', 'label': 'Cancel'}], | |
| True, 'ignore')) | |
| def schedule_unsupported(d: Draft, req: Request, index: int, answers: dict, after_hours_ok: bool = False) -> None: | |
| mods = req.content.mods | |
| if 'timed' in mods and answer_for(answers, 'schedule', index) != 'ignore': | |
| d.issue('START_END_NOT_SUPPORTED', 'blocking', | |
| 'Policies apply from the moment they are switched on until someone switches them off; they cannot ' | |
| 'start or end on a date by themselves.') | |
| d.ask(Question('schedule', 'The policy will apply as soon as it is switched on, with no end date. Continue?', | |
| [{'value': 'ignore', 'label': 'Yes'}, {'value': 'cancel', 'label': 'Cancel'}], True, 'ignore')) | |
| return | |
| timed = 'during_hours' in mods or (not after_hours_ok and (req.content.hours or 'after_hours' in mods)) | |
| if not timed: | |
| return | |
| if answer_for(answers, 'schedule', index) == 'ignore': | |
| return | |
| d.issue('SCHEDULE_NOT_SUPPORTED', 'blocking', | |
| f'{catalog.TYPE_LABEL[d.policyType]} policies apply at all times; they cannot be limited to certain hours.') | |
| d.ask(Question('schedule', 'The policy will apply at all hours. Continue?', | |
| [{'value': 'ignore', 'label': 'Yes, apply at all times'}, {'value': 'cancel', 'label': 'Cancel'}], | |
| True, 'ignore')) | |
| QUALIFIERS = { | |
| 'executables': 'kind of file (executables)', | |
| 'bulk': 'volume ("bulk")', | |
| } | |
| def qualifiers_unsupported(d: Draft, req: Request, index: int, answers: dict, handled: set[str] = frozenset()) -> None: | |
| """Words that narrow the request in a way this policy type cannot ("larger than 10 MB", "executables"). | |
| Ignoring them would make the policy broader than asked, so the admin confirms first.""" | |
| c = req.content | |
| found = [] | |
| if c.sizes and 'sizes' not in handled: | |
| found.append(f'size ({", ".join(c.sizes)})') | |
| for mod in ('executables', 'bulk'): | |
| if mod in c.mods and mod not in handled: | |
| found.append(QUALIFIERS[mod]) | |
| for s in c.situations: | |
| found.append(f'situation ("{s}")') | |
| if not found or answer_for(answers, 'qualifier', index) == 'ignore': | |
| return | |
| d.issue('QUALIFIER_NOT_SUPPORTED', 'blocking', | |
| f'This policy cannot be limited by {" or ".join(found)}; it would apply to everything it covers.', | |
| qualifiers=found) | |
| d.ask(Question('qualifier', f'The policy cannot be limited by {" or ".join(found)}. Apply it to everything?', | |
| [{'value': 'ignore', 'label': 'Yes, apply to everything'}, {'value': 'cancel', 'label': 'Cancel'}], | |
| True, 'ignore')) | |
| def size_bytes(s: str) -> int | None: | |
| """'5mb' -> 5242880.""" | |
| import re | |
| m = re.match(r'^([\d.]+)(kb|mb|gb|tb|kilobytes|megabytes|gigabytes)$', s) | |
| if not m: | |
| return None | |
| mult = {'kb': 1024, 'kilobytes': 1024, 'mb': 1024 ** 2, 'megabytes': 1024 ** 2, 'gb': 1024 ** 3, | |
| 'gigabytes': 1024 ** 3, 'tb': 1024 ** 4}[m.group(2)] | |
| try: | |
| return max(1, int(float(m.group(1)) * mult)) | |
| except ValueError: | |
| return None | |
| def domains_question(d: Draft, req: Request, index: int, answers: dict, doms: list[str], | |
| options: list[tuple[str, str]]) -> str | None: | |
| """What a domain named in a channel request is for. Returns the chosen role, or None (asked).""" | |
| if 'our_domain' in req.content.mods: | |
| return 'owned' | |
| ans = answer_for(answers, 'domains', index) | |
| if ans in [v for v, _ in options] or ans == 'ignore': | |
| return ans | |
| d.issue('DOMAINS_UNCLEAR', 'blocking', f'I am not sure what {", ".join(doms)} should do in this policy.', | |
| domains=doms) | |
| d.ask(Question('domains', f'What is {", ".join(doms)}?', | |
| [{'value': v, 'label': lbl} for v, lbl in options] + [{'value': 'ignore', 'label': 'Ignore it'}], | |
| True, 'ignore')) | |
| return None | |
| def exceptions_unsupported(d: Draft, req: Request, consumed: list[int], index: int, answers: dict, | |
| ctx: PlatformContext) -> None: | |
| left = [e for k, e in enumerate(req.exceptions) if k not in consumed and not e.says_nothing()] | |
| if not left or answer_for(answers, 'exception', index) == 'ignore': | |
| return | |
| desc = describe_content(left[0], ctx) | |
| groups = [ctx.group(g).name for e in left for g in e.groups if ctx.group(g)] | |
| hint = '' | |
| if groups and d.policyType == 'endpoint': | |
| hint = (f' For AI prompt rules, CLOAK\'s way to do this is a separate policy for {", ".join(groups)}: a ' | |
| f'group\'s own policies replace the org-wide prompt rules for that group. Device and browser ' | |
| f'controls (USB, printing, block lists) set org-wide cannot be lifted for one group.') | |
| d.issue('EXCEPTION_NOT_SUPPORTED', 'blocking', | |
| f'This kind of policy has no way to exempt {desc}.{hint}', exception=desc) | |
| d.ask(Question('exception', f'The policy cannot exempt {desc}; it would apply to them too. Continue?', | |
| [{'value': 'ignore', 'label': 'Yes, without the exception'}, {'value': 'cancel', 'label': 'Cancel'}], | |
| True, 'ignore')) | |
| def describe_content(c: Content, ctx: PlatformContext) -> str: | |
| parts: list[str] = [] | |
| parts += [ctx.group(g).name for g in c.groups if ctx.group(g)] | |
| parts += c.unknown_groups | |
| parts += [catalog.entity_label(e) for e in c.data.entity_set()] | |
| parts += c.domains + c.emails + c.serials + c.sites + c.apps + c.ai_sites + c.mods + c.words | |
| return ', '.join(dict.fromkeys(parts)) or 'that' | |
| def domains_of(c: Content) -> list[str]: | |
| hosts = set(catalog.ai_hosts()) | |
| return [x for x in c.domains if x not in hosts] | |
| def base_body(policy_type: str) -> dict: | |
| body = catalog.default_body(policy_type) | |
| # Copilot proposals are always saved inactive; the confirm screen has an explicit switch. | |
| body['active'] = False | |
| body['enabled'] = False | |
| return body | |