taskuary 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. taskuary/__init__.py +2 -0
  2. taskuary/agents.py +218 -0
  3. taskuary/artifacts.py +209 -0
  4. taskuary/aws.py +226 -0
  5. taskuary/azure.py +334 -0
  6. taskuary/blackboard.py +150 -0
  7. taskuary/channels.py +768 -0
  8. taskuary/ci.py +237 -0
  9. taskuary/cli.py +47 -0
  10. taskuary/coder.py +132 -0
  11. taskuary/config.py +62 -0
  12. taskuary/db.py +63 -0
  13. taskuary/desktop.py +72 -0
  14. taskuary/devtools.py +352 -0
  15. taskuary/digest.py +104 -0
  16. taskuary/docsync.py +124 -0
  17. taskuary/github.py +136 -0
  18. taskuary/histgen.py +220 -0
  19. taskuary/imapmail.py +150 -0
  20. taskuary/ingest.py +344 -0
  21. taskuary/learn.py +181 -0
  22. taskuary/llm.py +175 -0
  23. taskuary/logs.py +17 -0
  24. taskuary/mcp.py +78 -0
  25. taskuary/messengers.py +205 -0
  26. taskuary/mssql.py +64 -0
  27. taskuary/outbound.py +221 -0
  28. taskuary/phone.py +88 -0
  29. taskuary/pm.py +310 -0
  30. taskuary/policy.py +59 -0
  31. taskuary/proof.py +194 -0
  32. taskuary/proposals.py +132 -0
  33. taskuary/reports.py +508 -0
  34. taskuary/reshape.py +201 -0
  35. taskuary/responder.py +199 -0
  36. taskuary/routing.py +115 -0
  37. taskuary/scopes.py +88 -0
  38. taskuary/server.py +1506 -0
  39. taskuary/store.py +711 -0
  40. taskuary/templates/coder.md +35 -0
  41. taskuary/templates/digest.md +5 -0
  42. taskuary/templates/learned.md +31 -0
  43. taskuary/templates/soul.md +44 -0
  44. taskuary/templates/style.md +14 -0
  45. taskuary/templates/triage.md +22 -0
  46. taskuary/terminal.py +971 -0
  47. taskuary/toil.py +57 -0
  48. taskuary/triage.py +123 -0
  49. taskuary/verdicts.py +73 -0
  50. taskuary/web/assets/index-6GBZ9nXN.css +32 -0
  51. taskuary/web/assets/index-Cjj87C2X.js +401 -0
  52. taskuary/web/favicon.ico +0 -0
  53. taskuary/web/favicon.png +0 -0
  54. taskuary/web/index.html +25 -0
  55. taskuary/whatsapp/bridge.mjs +101 -0
  56. taskuary/whatsapp/package.json +11 -0
  57. taskuary-0.2.0.dist-info/METADATA +424 -0
  58. taskuary-0.2.0.dist-info/RECORD +62 -0
  59. taskuary-0.2.0.dist-info/WHEEL +5 -0
  60. taskuary-0.2.0.dist-info/entry_points.txt +3 -0
  61. taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
  62. taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/aws.py ADDED
@@ -0,0 +1,226 @@
1
+ """AWS connector - boto3 with keys from the card (the write-only secret IS the secret
2
+ access key); leave everything blank and boto3's default chain takes over (env vars,
3
+ ~/.aws/credentials, an instance role). Three report/tool types: 'aws' calls ANY service
4
+ operation, 's3_object' reads or lists a bucket, 'cloudwatch_logs' greps a log group.
5
+ """
6
+ import json
7
+ from datetime import datetime, timedelta
8
+ from loguru import logger
9
+
10
+
11
+ def _boto3():
12
+ try: import boto3
13
+ except ImportError:
14
+ # `pip install taskuary[aws]` is a TRAP on an install whose metadata predates the
15
+ # extra (an editable install pins its dist-info at install time): pip prints
16
+ # "does not provide the extra 'aws'", says everything is already satisfied, and
17
+ # installs nothing. Naming the package itself always works.
18
+ raise RuntimeError('boto3 is not installed - run: pip install boto3 '
19
+ '(or re-run pip install -e .[aws] from the repo)')
20
+ return boto3
21
+
22
+
23
+ # One card, many regions. CloudWatch log groups exist PER REGION - the same account shows a
24
+ # completely different set in us-east-1 and us-east-2 - so a single region field meant half an
25
+ # account was invisible with no sign that anything was missing. The field takes a list now
26
+ # ("us-east-2, us-east-1"); one value still behaves exactly as it did.
27
+ def regions(cfg: dict) -> list:
28
+ """The regions to sweep, in order. [None] = no region set, so boto3's own default chain
29
+ decides (env, ~/.aws/config, an instance role) - which is what a blank field always meant."""
30
+ out = [r.strip() for r in str(cfg.get('region') or '').replace(';', ',').replace(' ', ',').split(',') if r.strip()]
31
+ return list(dict.fromkeys(out)) or [None]
32
+
33
+
34
+ def _key(addr: str, region):
35
+ """What makes this object itself. A bucket is global - its name IS its identity, and the
36
+ region only says which endpoint to read it from. A log group is regional, so its name means
37
+ nothing without one."""
38
+ return addr if addr.startswith('s3://') else (addr, region)
39
+
40
+
41
+ def client(cfg: dict, service: str, region: str = None):
42
+ """`region` overrides the card - what a per-object call uses, because a discovered object
43
+ knows which region it was found in and the card's FIRST region is only a default."""
44
+ kw = {k: v for k, v in {'region_name': region or regions(cfg)[0],
45
+ 'aws_access_key_id': cfg.get('access_key_id'),
46
+ 'aws_secret_access_key': cfg.get('secret_access_key')}.items() if v}
47
+ return _boto3().client(service, **kw)
48
+
49
+
50
+ def test(cfg: dict) -> dict:
51
+ """STS caller identity - proves the keys (or the default chain) actually authenticate."""
52
+ try:
53
+ who = client(cfg, 'sts').get_caller_identity()
54
+ return {'ok': True, 'detail': f"authenticated · account {who['Account']} · {who['Arn']}"}
55
+ except Exception as e:
56
+ return {'ok': False, 'error': str(e)[:500]}
57
+
58
+
59
+ def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
60
+ """What can these keys SEE? Every S3 bucket and CloudWatch log group is registered as
61
+ a source with its own mode picker - report (default: selectable on the Reports tab,
62
+ nothing polled), feed (new items shown on the Timeline), tasks (through triage), or
63
+ off. A list that partially fails still registers what it found."""
64
+ # WHAT MAKES AN OBJECT ITSELF differs by service, and getting this wrong loses rows either
65
+ # way. A log group is (name, region): /aws/lambda/ingest in us-east-1 and the one in
66
+ # us-east-2 are unrelated groups that happen to share a name, and keying on the name alone
67
+ # registers whichever came first and drops the other. A BUCKET is its name and nothing
68
+ # else - S3 is one global namespace - so keying a bucket on (name, region) would file the
69
+ # same bucket twice the moment get_bucket_location answered differently than last time
70
+ # (a permission removed, a fallback taken), splitting its mode across two rows.
71
+ known = {}
72
+ for row in store.list_sources(active_only=False):
73
+ if row['Channel'] != 'aws': continue
74
+ known[_key(row['Address'], json.loads(row.get('ConfigJson') or '{}').get('region'))] = row
75
+ regs = regions(cfg)
76
+ found = [] # [(address, region)]
77
+ # S3 is one global namespace - listing it once per region would return the same buckets
78
+ # every time. Each bucket's OWN region is asked for separately, because a client pointed at
79
+ # the wrong one gets a 301 the moment it tries to read the objects.
80
+ try:
81
+ for b in client(cfg, 's3').list_buckets().get('Buckets') or []:
82
+ try:
83
+ loc = client(cfg, 's3').get_bucket_location(Bucket=b['Name']).get('LocationConstraint')
84
+ where = loc or 'us-east-1' # the API answers None for us-east-1, historically
85
+ except Exception:
86
+ where = regs[0] # no permission to ask: the card's first region
87
+ found.append((f"s3://{b['Name']}", where))
88
+ except Exception as e:
89
+ logger.warning(f'aws discovery: s3 list failed: {e}')
90
+ for reg in regs:
91
+ try:
92
+ # describe_log_groups caps at 50 PER CALL, so a single call silently truncates an
93
+ # account with hundreds of lambdas - page until the token runs out (bounded).
94
+ logs, tok, pages = client(cfg, 'logs', reg), None, 0
95
+ while pages < 8:
96
+ r = logs.describe_log_groups(limit=50, **({'nextToken': tok} if tok else {}))
97
+ found += [(f"logs://{g['logGroupName']}", reg) for g in r.get('logGroups') or []]
98
+ tok, pages = r.get('nextToken'), pages + 1
99
+ if not tok: break
100
+ except Exception as e:
101
+ # one bad region must not cost the others: a key without permission in eu-west-1 is
102
+ # a reason to skip eu-west-1, not to discover nothing
103
+ logger.warning(f'aws discovery: log groups failed in {reg or "the default region"}: {e}')
104
+ added, stamped = 0, 0
105
+ for addr, reg in found:
106
+ # a row discovered before the card knew about regions carries none. It is the SAME
107
+ # object, so it is adopted and stamped - not replaced by a second copy that would take
108
+ # the polling while the owner's mode pick sat on the row above it.
109
+ row = known.get(_key(addr, reg)) or known.get(_key(addr, None))
110
+ if row:
111
+ scfg = json.loads(row.get('ConfigJson') or '{}')
112
+ if not scfg.get('region') and reg:
113
+ store.save_source({'SourceId': row['SourceId'],
114
+ 'ConfigJson': json.dumps({**scfg, 'region': reg})}, actor)
115
+ known.pop(_key(addr, None), None)
116
+ known[_key(addr, reg)] = {**row, 'ConfigJson': json.dumps({**scfg, 'region': reg})}
117
+ stamped += 1
118
+ continue
119
+ store.save_source({'Channel': 'aws', 'Address': addr, 'ConnectorId': connector_id, 'Active': 1,
120
+ 'Owner': 'discovered', 'ConfigJson': json.dumps({'mode': 'report', 'region': reg})}, actor)
121
+ known[_key(addr, reg)] = {'SourceId': None, 'Address': addr,
122
+ 'ConfigJson': json.dumps({'mode': 'report', 'region': reg})}
123
+ added += 1
124
+ out = {'found': len(found), 'added': added, 'stamped': stamped, 'regions': [r for r in regs if r]}
125
+ if not found:
126
+ out['hint'] = ('the keys authenticate but list nothing - they need s3:ListAllMyBuckets and '
127
+ 'logs:DescribeLogGroups (AmazonS3ReadOnlyAccess + CloudWatchLogsReadOnlyAccess '
128
+ 'cover both). The log above says which call failed.')
129
+ return out
130
+
131
+
132
+ def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
133
+ """One discovered object in tasks/feed mode. s3://bucket -> a Timeline item per NEW
134
+ object; logs://group -> ONE batched item of the new matching events (default pattern:
135
+ errors - one row per log line would flood the funnel it feeds)."""
136
+ from .ingest import ingest_message
137
+ scfg = json.loads(src.get('ConfigJson') or '{}')
138
+ # the object's OWN region, stamped on it at discovery. Sources found before the card took
139
+ # a list have none, and the card's first region is exactly what they were found with.
140
+ reg = scfg.get('region')
141
+ addr, floor, n = src['Address'], since.strftime('%Y-%m-%d %H:%M:%S'), 0
142
+ if addr.startswith('s3://'):
143
+ bucket = addr[5:]
144
+ r = client(cfg, 's3', reg).list_objects_v2(Bucket=bucket, MaxKeys=200)
145
+ for o in r.get('Contents') or []:
146
+ at = o.get('LastModified')
147
+ stamp = at.astimezone().strftime('%Y-%m-%d %H:%M:%S') if hasattr(at, 'astimezone') else str(at)
148
+ if stamp < floor: continue
149
+ out = ingest_message(store, file_only=file_only, msg={
150
+ 'external_id': f"aws:{reg or '-'}:{bucket}:{o['Key']}:{stamp[:16]}", 'channel': 'aws',
151
+ 'subject': f"New in {addr}: {o['Key']}" + (f' ({reg})' if reg else ''),
152
+ 'body': f"[S3 object landed - {o.get('Size')} bytes]\ns3://{bucket}/{o['Key']}",
153
+ 'from_name': addr, 'conversation_id': f'aws:{addr}', 'sent_at': stamp,
154
+ 'source_name': addr}, llm=llm)
155
+ n += out['status'] != 'duplicate'
156
+ elif addr.startswith('logs://'):
157
+ group = addr[7:]
158
+ pat = scfg.get('pattern') or '?ERROR ?Exception ?FATAL'
159
+ ev = client(cfg, 'logs', reg).filter_log_events(logGroupName=group, filterPattern=pat, limit=100,
160
+ startTime=int(since.timestamp() * 1000)).get('events') or []
161
+ if ev:
162
+ lines = '\n'.join(f"{datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%H:%M:%S')} "
163
+ f"{(e.get('message') or '').strip()[:300]}" for e in ev[:50])
164
+ stamp = datetime.fromtimestamp(ev[-1]['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S')
165
+ out = ingest_message(store, file_only=file_only, msg={
166
+ 'external_id': f"awslogs:{reg or '-'}:{group}:{ev[-1].get('eventId') or stamp}", 'channel': 'aws',
167
+ 'subject': f"{len(ev)} matching log events in {group}" + (f' ({reg})' if reg else ''),
168
+ 'body': f"[CloudWatch {group} - pattern {pat}]\n{lines}",
169
+ 'from_name': addr, 'conversation_id': f'aws:{addr}', 'sent_at': stamp,
170
+ 'source_name': addr}, llm=llm)
171
+ n += out['status'] != 'duplicate'
172
+ return n
173
+
174
+
175
+ def dot_path(data, path):
176
+ for k in (path or '').split('.'):
177
+ if k: data = data[int(k)] if isinstance(data, list) else (data or {}).get(k)
178
+ return data
179
+
180
+
181
+ def run_aws(cfg: dict):
182
+ """{"service", "operation", "params": {...}, "path": "a.b"} - any boto3 call: service=s3
183
+ operation=list_buckets, service=logs operation=describe_log_groups, service=athena...
184
+ A list at `path` (or the first list in the response) comes back row-capped and honest."""
185
+ from .reports import row_limit, rows_out, BODY_CHARS
186
+ out = getattr(client(cfg, cfg['service']), cfg['operation'])(**(cfg.get('params') or {}))
187
+ if isinstance(out, dict): out.pop('ResponseMetadata', None)
188
+ data = dot_path(out, cfg.get('path')) if cfg.get('path') else out
189
+ if isinstance(data, dict) and not cfg.get('path'):
190
+ lists = [v for v in data.values() if isinstance(v, list)]
191
+ if len(lists) == 1 and len(data) == 1: data = lists[0] # the one-key list envelope
192
+ if isinstance(data, list):
193
+ lim, mine = row_limit(cfg)
194
+ return rows_out(data, lim, unit='items', mine=mine)
195
+ return 'ok', json.dumps(data, indent=1, default=str)[:BODY_CHARS]
196
+
197
+
198
+ def run_s3_object(cfg: dict):
199
+ """{"bucket", "key"} fetches the object itself (text head); {"bucket", "prefix"} lists
200
+ what's under it (key, size, modified)."""
201
+ from .reports import row_limit, rows_out, BODY_CHARS
202
+ s3 = client(cfg, 's3')
203
+ if cfg.get('key'):
204
+ o = s3.get_object(Bucket=cfg['bucket'], Key=cfg['key'])
205
+ raw = o['Body'].read(BODY_CHARS + 1)
206
+ size = int(o.get('ContentLength') or len(raw))
207
+ head = f"s3://{cfg['bucket']}/{cfg['key']} · {size} bytes" + (' (shown truncated)' if len(raw) > BODY_CHARS else '')
208
+ return head, raw.decode('utf-8', errors='replace')[:BODY_CHARS]
209
+ lim, mine = row_limit(cfg)
210
+ r = s3.list_objects_v2(Bucket=cfg['bucket'], Prefix=cfg.get('prefix') or '', MaxKeys=min(lim + 1, 1000))
211
+ rows = [{'key': o['Key'], 'size': o['Size'], 'modified': str(o['LastModified'])} for o in r.get('Contents') or []]
212
+ return rows_out(rows, lim, unit='objects', mine=mine)
213
+
214
+
215
+ def run_cloudwatch_logs(cfg: dict):
216
+ """{"log_group", "pattern", "hours": 24} - recent events from a CloudWatch log group.
217
+ Pattern uses CloudWatch filter syntax: '?ERROR ?Exception' means any of these words."""
218
+ from .reports import row_limit, rows_out
219
+ lim, mine = row_limit(cfg)
220
+ start = int((datetime.now() - timedelta(hours=float(cfg.get('hours') or 24))).timestamp() * 1000)
221
+ kw = {'logGroupName': cfg['log_group'], 'startTime': start, 'limit': min(lim + 1, 10000)}
222
+ if cfg.get('pattern'): kw['filterPattern'] = cfg['pattern']
223
+ ev = client(cfg, 'logs').filter_log_events(**kw).get('events') or []
224
+ rows = [{'at': datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S'),
225
+ 'stream': e.get('logStreamName'), 'message': (e.get('message') or '').strip()[:500]} for e in ev]
226
+ return rows_out(rows, lim, unit='events', mine=mine)
taskuary/azure.py ADDED
@@ -0,0 +1,334 @@
1
+ """Azure connector - client-credentials tokens over plain requests, the same app-registration
2
+ road Outlook/Teams already ride (leave the card blank and it borrows the Outlook app; one
3
+ registration can hold Graph permissions AND Azure RBAC roles). Three report/tool types:
4
+ 'azure' GETs ANY Azure Resource Manager path, 'azure_blob' reads or lists a storage
5
+ container, 'azure_logs' runs KQL against a Log Analytics workspace. The app needs a role
6
+ on the target: Reader for ARM, Storage Blob Data Reader, Log Analytics Reader.
7
+ """
8
+ import json, os, re, requests
9
+ from datetime import datetime
10
+ from loguru import logger
11
+
12
+ ARM = 'https://management.azure.com'
13
+ STORAGE_VER = '2021-08-06'
14
+
15
+
16
+ def token(cfg: dict, scope: str) -> str:
17
+ tid = cfg.get('tenant_id') or os.getenv('AZURE_TENANT_ID')
18
+ cid = cfg.get('client_id') or os.getenv('AZURE_CLIENT_ID')
19
+ sec = cfg.get('client_secret') or os.getenv('AZURE_CLIENT_SECRET')
20
+ if not (tid and cid and sec):
21
+ raise RuntimeError('need tenant_id + client_id + a client secret - set them on the Azure card, '
22
+ 'or set up the Outlook connector (its app is borrowed automatically)')
23
+ r = requests.post(f'https://login.microsoftonline.com/{tid}/oauth2/v2.0/token', timeout=20,
24
+ data={'client_id': cid, 'client_secret': sec,
25
+ 'grant_type': 'client_credentials', 'scope': scope})
26
+ if r.status_code != 200: raise RuntimeError(f'token failed ({r.status_code}): {r.text[:300]}')
27
+ return r.json()['access_token']
28
+
29
+
30
+ def _get(url, tok, **kw):
31
+ r = requests.get(url, headers={'Authorization': f'Bearer {tok}', 'x-ms-version': STORAGE_VER},
32
+ timeout=60, **kw)
33
+ if r.status_code >= 400: raise RuntimeError(f'{r.status_code}: {r.text[:300]}')
34
+ return r
35
+
36
+
37
+ def test(cfg: dict) -> dict:
38
+ """Mint a management token, then list what the app can actually SEE - a token with no
39
+ role assignments is a setup half-done, and a bare green would hide that."""
40
+ try:
41
+ tok = token(cfg, f'{ARM}/.default')
42
+ subs = _get(f'{ARM}/subscriptions', tok, params={'api-version': '2022-12-01'}).json().get('value') or []
43
+ if not subs:
44
+ return {'ok': True, 'detail': 'token OK, but the app sees no subscriptions - grant it a role '
45
+ '(e.g. Reader) on a subscription or resource group for ARM reads. '
46
+ 'Blob and Log Analytics reads use their own roles and may work already.'}
47
+ names = ', '.join(s.get('displayName') or s['subscriptionId'] for s in subs[:5])
48
+ return {'ok': True, 'detail': f'authenticated · {len(subs)} subscription(s): {names}'}
49
+ except Exception as e:
50
+ return {'ok': False, 'error': str(e)[:500]}
51
+
52
+
53
+ def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
54
+ """What can this app SEE? ARM enumerates the storage accounts (then their containers,
55
+ with the storage token) and Log Analytics workspaces across every visible subscription;
56
+ each is registered as a source with the same mode picker as AWS - report (default,
57
+ nothing polled), feed, tasks, or off. RBAC decides what enumerates; partial failures
58
+ register what did."""
59
+ known = {s['Address'] for s in store.list_sources(active_only=False) if s['Channel'] == 'azure'}
60
+ found, cfgs = [], {}
61
+ tok = token(cfg, f'{ARM}/.default')
62
+ subs = _get(f'{ARM}/subscriptions', tok, params={'api-version': '2022-12-01'}).json().get('value') or []
63
+ stok = None
64
+ for sub in subs[:5]:
65
+ sid = sub['subscriptionId']
66
+ try:
67
+ for sa in _get(f'{ARM}/subscriptions/{sid}/providers/Microsoft.Storage/storageAccounts',
68
+ tok, params={'api-version': '2023-01-01'}).json().get('value') or []:
69
+ acct = sa['name']
70
+ try:
71
+ stok = stok or token(cfg, 'https://storage.azure.com/.default')
72
+ xml = _get(f'https://{acct}.blob.core.windows.net/', stok, params={'comp': 'list'}).text
73
+ for cont in re.findall(r'<Name>(.*?)</Name>', xml)[:20]:
74
+ found.append(f'blob://{acct}/{cont}')
75
+ except Exception as e:
76
+ logger.warning(f'azure discovery: containers of {acct} failed: {e}')
77
+ found.append(f'blob://{acct}') # the account still shows; add /container by hand
78
+ except Exception as e:
79
+ logger.warning(f'azure discovery: storage accounts in {sid} failed: {e}')
80
+ try:
81
+ for ws in _get(f'{ARM}/subscriptions/{sid}/providers/Microsoft.OperationalInsights/workspaces',
82
+ tok, params={'api-version': '2022-10-01'}).json().get('value') or []:
83
+ wid = (ws.get('properties') or {}).get('customerId')
84
+ if not wid: continue
85
+ addr = f"law://{ws['name']}"
86
+ found.append(addr)
87
+ cfgs[addr] = {'workspace_id': wid}
88
+ except Exception as e:
89
+ logger.warning(f'azure discovery: workspaces in {sid} failed: {e}')
90
+ added = 0
91
+ for addr in found:
92
+ if addr in known: continue
93
+ store.save_source({'Channel': 'azure', 'Address': addr, 'ConnectorId': connector_id, 'Active': 1,
94
+ 'Owner': 'discovered',
95
+ 'ConfigJson': json.dumps({'mode': 'report', **cfgs.get(addr, {})})}, actor)
96
+ added += 1
97
+ out = {'found': len(found), 'added': added}
98
+ if not found: out['hint'] = _why_empty(cfg, tok, subs)
99
+ return out
100
+
101
+
102
+ def _why_empty(cfg, tok, subs) -> str:
103
+ """Nothing found is almost never 'nothing exists' - it is RBAC, and the shape of the
104
+ emptiness says WHICH gap. Azure hands out container reads and resource reads
105
+ separately, so an app can list 86 resource groups and still read nothing inside them."""
106
+ if not subs:
107
+ return ('the app has no role on any subscription - assign it Reader: Azure Portal → '
108
+ 'Subscriptions → your subscription → Access control (IAM) → Add role assignment')
109
+ sid = subs[0]['subscriptionId']
110
+ try:
111
+ rgs = len(_get(f'{ARM}/subscriptions/{sid}/resourcegroups', tok,
112
+ params={'api-version': '2021-04-01'}).json().get('value') or [])
113
+ res = len(_get(f'{ARM}/subscriptions/{sid}/resources', tok,
114
+ params={'api-version': '2021-04-01', '$top': 1}).json().get('value') or [])
115
+ except Exception as e:
116
+ return f'could not tell why: {str(e)[:120]}'
117
+ if rgs and not res:
118
+ return (f'the app sees the subscription and its {rgs} resource groups but CANNOT READ THE '
119
+ 'RESOURCES inside them - those are separate rights in Azure. Assign it Reader on '
120
+ 'the subscription (or on the resource groups holding your storage accounts and '
121
+ 'Log Analytics workspaces): Access control (IAM) → Add role assignment → Reader')
122
+ if not rgs:
123
+ return 'the app can see the subscription but none of its resource groups - it needs Reader on the subscription'
124
+ return ('the app can read resources, but this subscription holds no storage accounts and no Log '
125
+ 'Analytics workspaces (a VM estate with no diagnostics storage looks exactly like this)')
126
+
127
+
128
+ def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
129
+ """One discovered object in tasks/feed mode. blob://account/container -> a Timeline
130
+ item per NEW blob; law://workspace -> ONE batched item of new exception rows (the
131
+ source's own 'query' overrides the default KQL)."""
132
+ from .ingest import ingest_message
133
+ scfg = json.loads(src.get('ConfigJson') or '{}')
134
+ addr, floor, n = src['Address'], since.strftime('%Y-%m-%d %H:%M:%S'), 0
135
+ if addr.startswith('blob://') and '/' in addr[7:]:
136
+ acct, cont = addr[7:].split('/', 1)
137
+ tok = token(cfg, 'https://storage.azure.com/.default')
138
+ xml = _get(f'https://{acct}.blob.core.windows.net/{cont}', tok,
139
+ params={'restype': 'container', 'comp': 'list'}).text
140
+ for name, mod in re.findall(r'<Name>(.*?)</Name>.*?<Last-Modified>(.*?)</Last-Modified>', xml, re.S)[:200]:
141
+ try: stamp = datetime.strptime(mod, '%a, %d %b %Y %H:%M:%S GMT').astimezone().strftime('%Y-%m-%d %H:%M:%S')
142
+ except ValueError: continue
143
+ if stamp < floor: continue
144
+ out = ingest_message(store, file_only=file_only, msg={
145
+ 'external_id': f'azblob:{acct}/{cont}/{name}:{stamp[:16]}', 'channel': 'azure',
146
+ 'subject': f'New in {addr}: {name}',
147
+ 'body': f'[blob landed]\nhttps://{acct}.blob.core.windows.net/{cont}/{name}',
148
+ 'from_name': addr, 'conversation_id': f'azure:{addr}', 'sent_at': stamp,
149
+ 'source_name': addr}, llm=llm)
150
+ n += out['status'] != 'duplicate'
151
+ elif addr.startswith('law://') and scfg.get('workspace_id'):
152
+ hours = max(0.2, (datetime.now() - since.replace(tzinfo=None)).total_seconds() / 3600)
153
+ q = scfg.get('query') or 'AppExceptions | project TimeGenerated, ProblemId, OuterMessage | take 50'
154
+ try:
155
+ head, body = run_azure_logs({**cfg, 'workspace_id': scfg['workspace_id'], 'query': q,
156
+ 'hours': hours, 'max_rows': 50})
157
+ except Exception as e:
158
+ raise RuntimeError(f'{addr}: {e}') from e
159
+ if body.strip() and not head.startswith('0 '):
160
+ stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
161
+ out = ingest_message(store, file_only=file_only, msg={
162
+ 'external_id': f'azlaw:{addr}:{stamp[:16]}', 'channel': 'azure',
163
+ 'subject': f'{head} from {addr}',
164
+ 'body': f'[Log Analytics - {q[:100]}]\n{body[:8000]}',
165
+ 'from_name': addr, 'conversation_id': f'azure:{addr}', 'sent_at': stamp,
166
+ 'source_name': addr}, llm=llm)
167
+ n += out['status'] != 'duplicate'
168
+ return n
169
+
170
+
171
+ # ── Entra ID (the directory, over Graph) ────────────────────────────────────────────────
172
+ # The SAME app registration - one client-credentials token, a different scope. An app that
173
+ # reads mail for the Outlook card can read the directory too once it has the Graph
174
+ # APPLICATION permissions (User.Read.All, Group.Read.All, AuditLog.Read.All,
175
+ # Organization.Read.All); admin consent is what turns each one on.
176
+ GRAPH = 'https://graph.microsoft.com/v1.0'
177
+ GRAPH_PAGES = 12 # $top=999 a page; a 10k-user tenant stops being a report
178
+
179
+ def graph_get(cfg: dict, path: str, **params) -> list:
180
+ """Every page of a Graph collection, following @odata.nextLink. Advanced queries
181
+ ($filter on any(), $count) need the eventual-consistency header, so it always rides."""
182
+ tok = token(cfg, 'https://graph.microsoft.com/.default')
183
+ url, out, pages = f'{GRAPH}{path}', [], 0
184
+ while url and pages < GRAPH_PAGES:
185
+ r = requests.get(url, headers={'Authorization': f'Bearer {tok}', 'ConsistencyLevel': 'eventual'},
186
+ params=params or None, timeout=45)
187
+ if r.status_code >= 400: raise RuntimeError(f'graph {path} said {r.status_code}: {r.text[:300]}')
188
+ j = r.json()
189
+ out += j.get('value') or []
190
+ url, params, pages = j.get('@odata.nextLink'), None, pages + 1
191
+ return out
192
+
193
+
194
+ def run_entra_users(cfg: dict):
195
+ """{"filter": "...", "select": "..."} - the directory's people. accountEnabled is NOT in
196
+ Graph's default user payload, so it is selected explicitly: without it every disabled
197
+ account reads as active, which is the one thing an access review must not get wrong."""
198
+ from .reports import row_limit, rows_out
199
+ sel = cfg.get('select') or 'displayName,userPrincipalName,accountEnabled,jobTitle,department,createdDateTime'
200
+ rows = graph_get(cfg, '/users', **{'$select': sel, '$top': 999,
201
+ **({'$filter': cfg['filter']} if cfg.get('filter') else {})})
202
+ lim, mine = row_limit(cfg)
203
+ return rows_out(rows, lim, unit='users', mine=mine)
204
+
205
+
206
+ def run_entra_groups(cfg: dict):
207
+ """{"group": "<name or id>"} lists that group's TRANSITIVE members (nested groups
208
+ included - what a login actually inherits); blank lists the groups themselves."""
209
+ from .reports import row_limit, rows_out
210
+ lim, mine = row_limit(cfg)
211
+ g = (cfg.get('group') or '').strip()
212
+ if not g:
213
+ rows = graph_get(cfg, '/groups', **{'$select': 'displayName,id,mail,description', '$top': 999})
214
+ return rows_out(rows, lim, unit='groups', mine=mine)
215
+ gid = g
216
+ if not re.fullmatch(r'[0-9a-fA-F-]{36}', g):
217
+ hit = graph_get(cfg, '/groups', **{'$filter': f"displayName eq '{g}'", '$select': 'id,displayName'})
218
+ if not hit: raise RuntimeError(f'no group named {g!r}')
219
+ gid = hit[0]['id']
220
+ rows = graph_get(cfg, f'/groups/{gid}/transitiveMembers',
221
+ **{'$select': 'displayName,userPrincipalName,accountEnabled', '$top': 999})
222
+ # $select drops @odata.type, so a user is 'the thing with a UPN' - nested groups and
223
+ # devices come back through the same collection
224
+ rows = [r for r in rows if r.get('userPrincipalName')]
225
+ return rows_out(rows, lim, unit='members', mine=mine)
226
+
227
+
228
+ def run_entra_signins(cfg: dict):
229
+ """{"hours": 24, "failed_only": true} - sign-in activity. Needs AuditLog.Read.All AND
230
+ an Entra ID P1/P2 tenant; without the licence Graph answers 403 and says so."""
231
+ from .reports import row_limit, rows_out
232
+ from datetime import timedelta
233
+ since = (datetime.utcnow() - timedelta(hours=float(cfg.get('hours') or 24))).strftime('%Y-%m-%dT%H:%M:%SZ')
234
+ flt = f'createdDateTime ge {since}'
235
+ if cfg.get('failed_only'): flt += ' and status/errorCode ne 0'
236
+ rows = graph_get(cfg, '/auditLogs/signIns', **{'$filter': flt, '$top': 999})
237
+ out = [{'at': r.get('createdDateTime'), 'user': r.get('userPrincipalName'), 'app': r.get('appDisplayName'),
238
+ 'ip': r.get('ipAddress'), 'city': ((r.get('location') or {}).get('city')),
239
+ 'error': ((r.get('status') or {}).get('errorCode')),
240
+ 'reason': ((r.get('status') or {}).get('failureReason') or '')[:120]} for r in rows]
241
+ lim, mine = row_limit(cfg)
242
+ return rows_out(out, lim, unit='sign-ins', mine=mine)
243
+
244
+
245
+ def run_entra_licenses(cfg: dict):
246
+ """The tenant's licence SKUs and how many seats are actually consumed - the report that
247
+ finds the seats nobody is using. Needs Organization.Read.All."""
248
+ from .reports import row_limit, rows_out
249
+ rows = graph_get(cfg, '/subscribedSkus')
250
+ out = []
251
+ for s in rows:
252
+ p = s.get('prepaidUnits') or {}
253
+ enabled, used = int(p.get('enabled') or 0), int(s.get('consumedUnits') or 0)
254
+ out.append({'sku': s.get('skuPartNumber'), 'enabled': enabled, 'consumed': used,
255
+ 'spare': enabled - used, 'warning': int(p.get('warning') or 0),
256
+ 'suspended': int(p.get('suspended') or 0)})
257
+ out.sort(key=lambda x: -x['spare'])
258
+ lim, mine = row_limit(cfg)
259
+ return rows_out(out, lim, unit='SKUs', mine=mine)
260
+
261
+
262
+ def test_entra(cfg: dict) -> dict:
263
+ """Which directory reads this app actually has - each permission probed separately, so
264
+ the answer names the ones missing instead of failing on the first."""
265
+ out, ok = {}, []
266
+ for label, path, params in (('users', '/users', {'$top': 1, '$select': 'displayName'}),
267
+ ('groups', '/groups', {'$top': 1, '$select': 'displayName'}),
268
+ ('licences', '/subscribedSkus', {}),
269
+ ('sign-in logs', '/auditLogs/signIns', {'$top': 1})):
270
+ try:
271
+ graph_get(cfg, path, **params)
272
+ out[label] = 'ok'; ok.append(label)
273
+ except Exception as e:
274
+ out[label] = 'no' if '403' in str(e) or '401' in str(e) else str(e)[:80]
275
+ if not ok:
276
+ return {'ok': False, 'error': 'the app has no directory permissions - grant Graph APPLICATION '
277
+ 'permissions (User.Read.All, Group.Read.All, Organization.Read.All, '
278
+ 'AuditLog.Read.All) and click Grant admin consent'}
279
+ missing = [k for k, v in out.items() if v != 'ok']
280
+ return {'ok': True, 'detail': 'Entra reads available: ' + ', '.join(ok)
281
+ + (f" · not permitted: {', '.join(missing)}" if missing else '')}
282
+
283
+
284
+ def run_azure(cfg: dict):
285
+ """{"path": "/subscriptions/<id>/..." (or a full https URL), "api_version",
286
+ "path_expr": "a.b"} - GET any ARM object with the app's token. An ARM list (a 'value'
287
+ array) comes back row-capped and honest; a single object comes back as JSON."""
288
+ from .reports import row_limit, rows_out, BODY_CHARS
289
+ from .aws import dot_path
290
+ url = cfg['path'] if str(cfg.get('path') or '').startswith('http') else ARM + cfg['path']
291
+ params = dict(cfg.get('params') or {})
292
+ params.setdefault('api-version', cfg.get('api_version') or '2022-12-01')
293
+ data = _get(url, token(cfg, f'{ARM}/.default'), params=params).json()
294
+ if cfg.get('path_expr'): data = dot_path(data, cfg['path_expr'])
295
+ if isinstance(data, dict) and isinstance(data.get('value'), list): data = data['value']
296
+ if isinstance(data, list):
297
+ lim, mine = row_limit(cfg)
298
+ return rows_out(data, lim, unit='items', mine=mine)
299
+ return 'ok', json.dumps(data, indent=1, default=str)[:BODY_CHARS]
300
+
301
+
302
+ def run_azure_blob(cfg: dict):
303
+ """{"account", "container", "blob"} reads the blob (text head); {"account", "container",
304
+ "prefix"} lists. Role: 'Storage Blob Data Reader' on the account (or container)."""
305
+ from .reports import row_limit, rows_out, BODY_CHARS
306
+ tok = token(cfg, 'https://storage.azure.com/.default')
307
+ base = f"https://{cfg['account']}.blob.core.windows.net/{cfg['container']}"
308
+ if cfg.get('blob'):
309
+ r = _get(f"{base}/{cfg['blob']}", tok)
310
+ head = (f"{cfg['container']}/{cfg['blob']} · {len(r.content)} bytes"
311
+ + (' (shown truncated)' if len(r.content) > BODY_CHARS else ''))
312
+ return head, r.content[:BODY_CHARS].decode('utf-8', errors='replace')
313
+ xml = _get(base, tok, params={'restype': 'container', 'comp': 'list', 'prefix': cfg.get('prefix') or ''}).text
314
+ rows = [{'name': n, 'modified': m, 'size': int(s)} for n, m, s in
315
+ re.findall(r'<Name>(.*?)</Name>.*?<Last-Modified>(.*?)</Last-Modified>.*?<Content-Length>(\d+)</Content-Length>',
316
+ xml, re.S)]
317
+ lim, mine = row_limit(cfg)
318
+ return rows_out(rows, lim, unit='blobs', mine=mine)
319
+
320
+
321
+ def run_azure_logs(cfg: dict):
322
+ """{"workspace_id", "query" (KQL), "hours": 24} - Log Analytics: app exceptions,
323
+ sign-ins, anything the workspace collects. Role: 'Log Analytics Reader'."""
324
+ from .reports import row_limit, rows_out
325
+ tok = token(cfg, 'https://api.loganalytics.io/.default')
326
+ r = requests.post(f"https://api.loganalytics.io/v1/workspaces/{cfg['workspace_id']}/query",
327
+ headers={'Authorization': f'Bearer {tok}'}, timeout=60,
328
+ json={'query': cfg['query'], 'timespan': f"PT{float(cfg.get('hours') or 24):g}H"})
329
+ if r.status_code >= 400: raise RuntimeError(f'{r.status_code}: {r.text[:300]}')
330
+ t = (r.json().get('tables') or [{}])[0]
331
+ cols = [c['name'] for c in t.get('columns') or []]
332
+ rows = [dict(zip(cols, row)) for row in t.get('rows') or []]
333
+ lim, mine = row_limit(cfg)
334
+ return rows_out(rows, lim, mine=mine)