aac-cli 0.2.5__tar.gz → 0.2.7__tar.gz

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 (54) hide show
  1. {aac_cli-0.2.5 → aac_cli-0.2.7}/PKG-INFO +29 -6
  2. aac_cli-0.2.5/aac_cli.egg-info/PKG-INFO → aac_cli-0.2.7/README.md +22 -26
  3. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/__init__.py +1 -1
  4. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/cli.py +74 -16
  5. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/data/renderer-source.json +2 -2
  6. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/aeg_cli.py +4 -1
  7. aac_cli-0.2.7/aac_cli/aeg_list.py +173 -0
  8. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/agent_cli.py +46 -5
  9. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/agent_layout.py +0 -18
  10. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/agent_record.py +1 -1
  11. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/cli.py +23 -14
  12. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/config.py +25 -85
  13. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/init_cli.py +0 -3
  14. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/profiles.py +6 -0
  15. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/reference.py +11 -1
  16. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/registration_state.py +9 -48
  17. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/trace_query.py +20 -5
  18. aac_cli-0.2.5/README.md → aac_cli-0.2.7/aac_cli.egg-info/PKG-INFO +49 -4
  19. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli.egg-info/SOURCES.txt +1 -0
  20. {aac_cli-0.2.5 → aac_cli-0.2.7}/pyproject.toml +9 -2
  21. {aac_cli-0.2.5 → aac_cli-0.2.7}/LICENSE +0 -0
  22. {aac_cli-0.2.5 → aac_cli-0.2.7}/MANIFEST.in +0 -0
  23. {aac_cli-0.2.5 → aac_cli-0.2.7}/NOTICE +0 -0
  24. {aac_cli-0.2.5 → aac_cli-0.2.7}/THIRD_PARTY_NOTICES.md +0 -0
  25. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/__main__.py +0 -0
  26. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/__init__.py +0 -0
  27. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/data/action-taken-v1.json +0 -0
  28. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/data/predicates-v1.json +0 -0
  29. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/from_sidecar.py +0 -0
  30. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/inputs.py +0 -0
  31. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/model.py +0 -0
  32. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/narrative.py +0 -0
  33. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/schemas.py +0 -0
  34. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/temporal.py +0 -0
  35. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/timeline_source.py +0 -0
  36. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/_aeg/visualizer.py +0 -0
  37. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/admin_key_pem.py +0 -0
  38. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/agent_config.py +0 -0
  39. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/agent_health.py +0 -0
  40. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/api_key_rotation_state.py +0 -0
  41. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/chain_list.py +0 -0
  42. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/config_render.py +0 -0
  43. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/dev_material.py +0 -0
  44. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/idp_recovery.py +0 -0
  45. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/material_cases.py +0 -0
  46. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/secure_files.py +0 -0
  47. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/sso_login.py +0 -0
  48. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli/supplied_material.py +0 -0
  49. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli.egg-info/dependency_links.txt +0 -0
  50. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli.egg-info/requires.txt +0 -0
  51. {aac_cli-0.2.5 → aac_cli-0.2.7}/aac_cli.egg-info/top_level.txt +0 -0
  52. {aac_cli-0.2.5 → aac_cli-0.2.7}/launchers/aac +0 -0
  53. {aac_cli-0.2.5 → aac_cli-0.2.7}/launchers/aeg +0 -0
  54. {aac_cli-0.2.5 → aac_cli-0.2.7}/setup.cfg +0 -0
@@ -1,10 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aac-cli
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
5
5
  Author: Agent Authority Cloud Project
6
6
  License-Expression: Apache-2.0
7
- Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/aeg.html
7
+ Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/cli/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
8
13
  Requires-Python: >=3.11
9
14
  Description-Content-Type: text/markdown
10
15
  License-File: LICENSE
@@ -32,6 +37,10 @@ incoming ones against public trust material the tenant publishes.
32
37
  New to AAC? [What AAC is for](https://cascadeauth.github.io/aac-starter-guide/#what-aac-is-for)
33
38
  explains what delegated authority between agents buys you, and what it does not.
34
39
 
40
+ The [CLI user guide and complete command reference](https://cascadeauth.github.io/aac-starter-guide/cli/)
41
+ cover developer and enterprise onboarding, everyday administration and recovery.
42
+ Select documentation matching `aac --version`.
43
+
35
44
  The `aac` command is how a tenant's operator does everything that is not
36
45
  done by the sidecar itself: register the tenant, sign in, manage keys,
37
46
  workloads and domains, and prepare a runnable local workload. Everything
@@ -59,7 +68,7 @@ pip install aac-cli
59
68
  aac --version
60
69
  ```
61
70
 
62
- Python 3.11 or newer is required.
71
+ Python 3.11 to 3.14 is supported. Release checks test all four versions.
63
72
 
64
73
  > **Mind the name.** `pip install aac` installs an unrelated project that
65
74
  > happens to share the acronym. The AAC package is **`aac-cli`**; the
@@ -69,8 +78,13 @@ Python 3.11 or newer is required.
69
78
 
70
79
  Installing or upgrading `aac-cli` supplies both `aac` and `aeg`.
71
80
  Use the [AEG guide and command reference](https://cascadeauth.github.io/aac-starter-guide/aeg.html)
72
- for rendering local evidence, selecting a profile, and migrating an older
73
- standalone AEG installation.
81
+ for listing local and central observations, rendering evidence, and selecting a profile.
82
+
83
+ `aeg list --profile PROFILE --since 7d` discovers participant-visible central
84
+ roots. Add `--events FILE` and/or `--actions FILE` for a hybrid listing with local
85
+ task references and a `local`, `control-plane` or `both` source column. Omit the
86
+ profile for offline local listing. Central fetches are bounded and expose
87
+ continuation; forwarding remains best-effort. Local files are never uploaded.
74
88
 
75
89
  ## Guided setup using `aac init`
76
90
 
@@ -381,7 +395,7 @@ issues them for those names or checks supplied certificates against them; renewa
381
395
  keeps those names. Applying a different name set requires the installed certificate
382
396
  to cover it already: supplied issuers can issue a certificate covering both sets
383
397
  before the change. A CLI-issued agent needing new names must be created under a
384
- new name with the intended configuration. No old agent-record format is migrated.
398
+ new name with the intended configuration.
385
399
 
386
400
  Central forwarding retains the local sink and sends only permitted metadata.
387
401
  It is asynchronous and best-effort; a local event does not prove central delivery.
@@ -791,6 +805,15 @@ AAC.
791
805
 
792
806
  ## Output and exit codes
793
807
 
808
+ For scripts, `aac profile show NAME --field tenant-id` prints the effective
809
+ tenant ID (including `AAC_TENANT_ID` overrides), and
810
+ `aac tenant assign-hosted-domain --field trust-domain` prints the validated
811
+ server-returned domain. Existing agents expose read-only identities through
812
+ `aac agent status --agent NAME --field tenant-id`, `--field hosted-trust-domain`
813
+ or `--field workload-spiffe-id`. A successful scalar is one unquoted line;
814
+ an unavailable value or unhealthy agent fails with empty stdout. `--field`
815
+ cannot be combined with explicit `--output`, or with agent `--remote`.
816
+
794
817
  Every command that reports a result prints one JSON document to standard
795
818
  output by default, with progress notes and diagnostics on standard error,
796
819
  so the JSON stays parseable in scripts; `--output table` prints a readable
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: aac-cli
3
- Version: 0.2.5
4
- Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
5
- Author: Agent Authority Cloud Project
6
- License-Expression: Apache-2.0
7
- Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/aeg.html
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- License-File: NOTICE
12
- License-File: THIRD_PARTY_NOTICES.md
13
- Requires-Dist: httpx>=0.28
14
- Requires-Dist: cryptography>=42.0
15
- Requires-Dist: PyYAML>=6.0
16
- Requires-Dist: pyvis<0.4,>=0.3.2
17
- Requires-Dist: jsonschema>=4.18
18
- Provides-Extra: test
19
- Requires-Dist: pytest>=8.0; extra == "test"
20
- Requires-Dist: pytest-httpx>=0.30; extra == "test"
21
- Dynamic: license-file
22
-
23
1
  # aac-cli — the `aac` command
24
2
 
25
3
  `aac` is the command-line tool for tenants of Agent Authority Cloud (AAC).
@@ -32,6 +10,10 @@ incoming ones against public trust material the tenant publishes.
32
10
  New to AAC? [What AAC is for](https://cascadeauth.github.io/aac-starter-guide/#what-aac-is-for)
33
11
  explains what delegated authority between agents buys you, and what it does not.
34
12
 
13
+ The [CLI user guide and complete command reference](https://cascadeauth.github.io/aac-starter-guide/cli/)
14
+ cover developer and enterprise onboarding, everyday administration and recovery.
15
+ Select documentation matching `aac --version`.
16
+
35
17
  The `aac` command is how a tenant's operator does everything that is not
36
18
  done by the sidecar itself: register the tenant, sign in, manage keys,
37
19
  workloads and domains, and prepare a runnable local workload. Everything
@@ -59,7 +41,7 @@ pip install aac-cli
59
41
  aac --version
60
42
  ```
61
43
 
62
- Python 3.11 or newer is required.
44
+ Python 3.11 to 3.14 is supported. Release checks test all four versions.
63
45
 
64
46
  > **Mind the name.** `pip install aac` installs an unrelated project that
65
47
  > happens to share the acronym. The AAC package is **`aac-cli`**; the
@@ -69,8 +51,13 @@ Python 3.11 or newer is required.
69
51
 
70
52
  Installing or upgrading `aac-cli` supplies both `aac` and `aeg`.
71
53
  Use the [AEG guide and command reference](https://cascadeauth.github.io/aac-starter-guide/aeg.html)
72
- for rendering local evidence, selecting a profile, and migrating an older
73
- standalone AEG installation.
54
+ for listing local and central observations, rendering evidence, and selecting a profile.
55
+
56
+ `aeg list --profile PROFILE --since 7d` discovers participant-visible central
57
+ roots. Add `--events FILE` and/or `--actions FILE` for a hybrid listing with local
58
+ task references and a `local`, `control-plane` or `both` source column. Omit the
59
+ profile for offline local listing. Central fetches are bounded and expose
60
+ continuation; forwarding remains best-effort. Local files are never uploaded.
74
61
 
75
62
  ## Guided setup using `aac init`
76
63
 
@@ -381,7 +368,7 @@ issues them for those names or checks supplied certificates against them; renewa
381
368
  keeps those names. Applying a different name set requires the installed certificate
382
369
  to cover it already: supplied issuers can issue a certificate covering both sets
383
370
  before the change. A CLI-issued agent needing new names must be created under a
384
- new name with the intended configuration. No old agent-record format is migrated.
371
+ new name with the intended configuration.
385
372
 
386
373
  Central forwarding retains the local sink and sends only permitted metadata.
387
374
  It is asynchronous and best-effort; a local event does not prove central delivery.
@@ -791,6 +778,15 @@ AAC.
791
778
 
792
779
  ## Output and exit codes
793
780
 
781
+ For scripts, `aac profile show NAME --field tenant-id` prints the effective
782
+ tenant ID (including `AAC_TENANT_ID` overrides), and
783
+ `aac tenant assign-hosted-domain --field trust-domain` prints the validated
784
+ server-returned domain. Existing agents expose read-only identities through
785
+ `aac agent status --agent NAME --field tenant-id`, `--field hosted-trust-domain`
786
+ or `--field workload-spiffe-id`. A successful scalar is one unquoted line;
787
+ an unavailable value or unhealthy agent fails with empty stdout. `--field`
788
+ cannot be combined with explicit `--output`, or with agent `--remote`.
789
+
794
790
  Every command that reports a result prints one JSON document to standard
795
791
  output by default, with progress notes and diagnostics on standard error,
796
792
  so the JSON stays parseable in scripts; `--output table` prints a readable
@@ -10,4 +10,4 @@ entrypoint (`aac_cli.cli:entrypoint`).
10
10
  # test in bin/tests/test_cli_version_parity.py. A literal keeps the
11
11
  # shipped code free of import-time metadata lookups (and their
12
12
  # not-installed failure mode).
13
- __version__ = "0.2.5"
13
+ __version__ = "0.2.7"
@@ -39,7 +39,7 @@ def parser(
39
39
  commands = result.add_subparsers(dest="command", required=True)
40
40
  render = commands.add_parser("render", help="Write a self-contained interactive HTML graph.")
41
41
  listing = commands.add_parser(
42
- "list", help="List candidate roots from explicitly supplied local files."
42
+ "list", help="List roots from local files, a control-plane profile, or both."
43
43
  )
44
44
  for command in (render, listing):
45
45
  command.add_argument(
@@ -72,9 +72,18 @@ def parser(
72
72
  selector.add_argument("--root-token-id", metavar="ID")
73
73
  selector.add_argument("--mint-response", type=Path, metavar="FILE")
74
74
  render.add_argument("--output", type=Path, required=True, metavar="FILE")
75
- listing.add_argument("--task-ref", help="Exact local task reference.")
75
+ listing.add_argument(
76
+ "--profile", help="Query this AAC CLI profile; with files, join by root ID."
77
+ )
78
+ listing.add_argument("--task-ref", help="Exact local task reference; requires local files.")
79
+ listing.add_argument("--limit", type=int, help="Central page size, 1..200 (default 50).")
80
+ listing.add_argument("--max-pages", type=int, help="Central fetch bound, 1..20 (default 1).")
81
+ listing.add_argument("--page-token", help="Continue central enumeration; omit --since.")
76
82
  time_start = listing.add_mutually_exclusive_group()
77
- time_start.add_argument("--since", help="Look back from now, e.g. 24h or 7d.")
83
+ time_start.add_argument(
84
+ "--since",
85
+ help="Look back, e.g. 24h or 7d; online defaults to 24h, maximum 31d. Local also accepts m.",
86
+ )
78
87
  time_start.add_argument(
79
88
  "--from", dest="from_time", help="Inclusive ISO 8601 observation time with timezone."
80
89
  )
@@ -185,11 +194,21 @@ def _date(value: str) -> datetime:
185
194
  raise InputError(f"invalid time {value!r}; use ISO 8601 with timezone") from exc
186
195
 
187
196
 
188
- def _list(args, events) -> int:
197
+ def _list(args, events, list_query) -> int:
198
+ if args.profile is not None:
199
+ if not args.profile.strip():
200
+ raise InputError("--profile must name an existing AAC CLI profile")
201
+ if args.task_ref is not None and not (args.events or args.actions):
202
+ raise InputError(
203
+ "--task-ref requires local files; the control plane stores no task references"
204
+ )
205
+ if list_query is None:
206
+ raise InputError("online listing requires the aeg command supplied by aac-cli")
207
+ return list_query(args, events)
189
208
  if not (args.events or args.actions):
190
- raise InputError(
191
- "local list requires --events and/or --actions; profile and hybrid listing belong to a later release"
192
- )
209
+ raise InputError("supply --events, --actions and/or --profile to list roots")
210
+ if any(value is not None for value in (args.limit, args.max_pages, args.page_token)):
211
+ raise InputError("--limit, --max-pages and --page-token require --profile")
193
212
  start = _date(args.from_time) if args.from_time else None
194
213
  stop = _date(args.to_time) if args.to_time else None
195
214
  if args.since:
@@ -204,6 +223,13 @@ def _list(args, events) -> int:
204
223
  raise InputError("--since duration is outside supported range") from exc
205
224
  if start and stop and start > stop:
206
225
  raise InputError("--from/--since must precede --to")
226
+ rows, diagnostics = local_rows(events, args.task_ref, start, stop)
227
+ print_listing(args.output, rows, diagnostics)
228
+ return 0
229
+
230
+
231
+ def local_rows(events, task_ref, start, stop):
232
+ """Select local activity, retaining older evidence for token/root correlation."""
207
233
  roots, mapping = roots_and_mapping(events)
208
234
  diagnostics = [
209
235
  f"{e.raw_event.get('_source', e.source)}: unmatched action {e.token_id}; supply sidecar root mapping"
@@ -230,7 +256,7 @@ def _list(args, events) -> int:
230
256
  or (e.raw_event.get("action_payload") or {}).get("task_ref")
231
257
  }
232
258
  )
233
- if args.task_ref and args.task_ref not in task_refs:
259
+ if task_ref is not None and task_ref not in task_refs:
234
260
  continue
235
261
  observed = [
236
262
  e
@@ -240,36 +266,68 @@ def _list(args, events) -> int:
240
266
  ]
241
267
  if not observed:
242
268
  continue
243
- times = sorted(e.timestamp_iso for e in observed)
269
+ times = sorted(_date(e.timestamp_iso) for e in observed)
244
270
  rows.append(
245
271
  {
246
272
  "root_token_id": root,
247
273
  "task_refs": task_refs,
248
- "first_observed": times[0],
249
- "last_observed": times[-1],
274
+ "first_observed": times[0].isoformat(),
275
+ "last_observed": times[-1].isoformat(),
250
276
  "sources": "local",
251
277
  "evidence_sources": sorted(
252
278
  {str(e.raw_event.get("_source", e.source)) for e in observed}
253
279
  ),
254
280
  }
255
281
  )
256
- if args.output == "json":
257
- print(json.dumps({"chains": rows, "diagnostics": diagnostics}, indent=2))
282
+ return rows, diagnostics
283
+
284
+
285
+ def print_listing(output, rows, diagnostics, coverage=None):
286
+ if output == "json":
287
+ body = {"chains": rows, "diagnostics": diagnostics}
288
+ if coverage is not None:
289
+ body.update(coverage)
290
+ print(json.dumps(body, indent=2))
258
291
  else:
292
+ if coverage is not None:
293
+ central = coverage["central"]
294
+ print(
295
+ "Central observations may be incomplete (best-effort forwarding; live pagination)."
296
+ )
297
+ if coverage["from"] is None or coverage["to"] is None:
298
+ print("Activity interval: unknown (continuation failed)")
299
+ else:
300
+ print(f"Activity interval: [{coverage['from']}, {coverage['to']})")
301
+ print(f"Central fetch: {central['status']}; {central['pages_fetched']} page(s).")
302
+ print("Sources describe this query; local does not mean never received centrally.")
303
+ print(
304
+ "Times and hop information are observed, not execution or business-completion totals."
305
+ )
259
306
  print("ROOT TOKEN ID | TASK REFERENCES | FIRST OBSERVED (UTC) | SOURCES")
260
307
  for row in rows:
261
308
  print(
262
- f"{row['root_token_id']} | {', '.join(row['task_refs']) or '(unknown)'} | {row['first_observed']} | local"
309
+ f"{row['root_token_id']} | {', '.join(row['task_refs']) or '(unknown)'} | {row['first_observed']} | {row['sources']}"
263
310
  )
311
+ if coverage is not None and coverage["central"]["next_page_token"]:
312
+ cursor = coverage["central"]["next_page_token"]
313
+ if coverage["central"]["status"] == "failed":
314
+ print(
315
+ f"Retry with --page-token {cursor} only if the failure was transient; "
316
+ "otherwise restart the original query."
317
+ )
318
+ else:
319
+ print(
320
+ f"Continue with --page-token {cursor} (same profile/files/filter; omit --since)."
321
+ )
264
322
  for diagnostic in diagnostics:
265
323
  print(diagnostic, file=sys.stderr)
266
- return 0
267
324
 
268
325
 
269
326
  def main(
270
327
  argv: list[str] | None = None,
271
328
  *,
272
329
  query: Query | None = None,
330
+ list_query: Callable[[argparse.Namespace, list], int] | None = None,
273
331
  prog: str = "aeg",
274
332
  version_text: str = "shared AAC renderer",
275
333
  ) -> int:
@@ -280,7 +338,7 @@ def main(
280
338
  return (
281
339
  _render(args, events, query or _query)
282
340
  if args.command == "render"
283
- else _list(args, events)
341
+ else _list(args, events, list_query)
284
342
  )
285
343
  except (InputError, ValueError, OSError) as exc:
286
344
  for diagnostic in unjoinable_diagnostics(events):
@@ -1,8 +1,8 @@
1
1
  {
2
- "content_sha256": "e444d19d48410200f040c05bba84d3e2426ecb5ba350430203cad5df74f4104d",
2
+ "content_sha256": "f0477c0f8e73bffe5e111842d8aca3f5745345acb8cb58bed911deba7d826798",
3
3
  "files": {
4
4
  "__init__.py": "80d80a1ba35353d271cddf02532801c60b57f5ec06fb268ae823747742ce8979",
5
- "cli.py": "b413ca8fffa51f0874deeaad47c16a2bed4ce0ec769074dd566bba3e5480566b",
5
+ "cli.py": "8fa9605246ba0ddbd24e6034b9582049fac11df139d47ee9e2ee0f322ff53c4c",
6
6
  "data/action-taken-v1.json": "d863343afe7f314646a73cf063480a7e650f93857d2ed5ec3a7b1f9838671fdf",
7
7
  "data/predicates-v1.json": "35e83a29feada10e8cfbef7009245146a9717d4f4fd58d74124e0ec91ae922c8",
8
8
  "from_sidecar.py": "6ac023f6925a13e94e0fee762656a02ca98981f2add194b052e9014ca48e2219",
@@ -21,6 +21,7 @@ def main(argv: list[str] | None = None) -> int:
21
21
  return 0
22
22
  from ._aeg import cli
23
23
  from ._aeg.inputs import InputError, read_trace
24
+ from .aeg_list import list_chains
24
25
 
25
26
  def query(profile: str, root: str):
26
27
  from .trace_query import query_trace
@@ -30,7 +31,9 @@ def main(argv: list[str] | None = None) -> int:
30
31
  except (ValueError, TypeError) as exc:
31
32
  raise InputError(str(exc)) from exc
32
33
 
33
- return cli.main(argv, query=query, prog="aeg", version_text=version_text())
34
+ return cli.main(
35
+ argv, query=query, list_query=list_chains, prog="aeg", version_text=version_text()
36
+ )
34
37
 
35
38
 
36
39
  def entrypoint() -> None:
@@ -0,0 +1,173 @@
1
+ """Bounded AEG discovery over the public chain-list JSON contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from ._aeg.cli import _date, local_rows, print_listing
8
+ from ._aeg.inputs import InputError, token_id
9
+ from .chain_list import query_params
10
+ from .trace_query import query_chains
11
+
12
+
13
+ def _observed_time(value) -> str:
14
+ if type(value) is not int:
15
+ raise ValueError("expected integer observation time")
16
+ return datetime.fromtimestamp(value, timezone.utc).isoformat()
17
+
18
+
19
+ def _page(body, params, previous_root):
20
+ """Validate the complete page before adding any of its rows to the result."""
21
+ try:
22
+ if (
23
+ type(body["schema_version"]) is not int
24
+ or body["schema_version"] != 1
25
+ or body["pagination"] != "live"
26
+ or body["central_evidence"] != "best_effort"
27
+ ):
28
+ raise ValueError("expected chain-list JSON v1 with live/best-effort evidence")
29
+ if not isinstance(body["from"], str) or not isinstance(body["to"], str):
30
+ raise ValueError("expected resolved central interval")
31
+ window = query_params(
32
+ since=None,
33
+ start=body["from"],
34
+ end=body["to"],
35
+ limit=body["limit"],
36
+ page_token=None,
37
+ )
38
+ if type(body["limit"]) is not int:
39
+ raise ValueError("expected integer page limit")
40
+ if any(window[key] != value for key, value in params.items() if key != "cursor"):
41
+ raise ValueError("central response changed the requested interval or limit")
42
+ more, cursor = body["has_more"], body["next_page_token"]
43
+ if (
44
+ type(more) is not bool
45
+ or (more and (not isinstance(cursor, str) or not 1 <= len(cursor) <= 2048))
46
+ or (not more and cursor is not None)
47
+ ):
48
+ raise ValueError("invalid central continuation")
49
+ if not isinstance(body["chains"], list) or len(body["chains"]) > body["limit"]:
50
+ raise ValueError("invalid central page rows")
51
+ if more and not body["chains"]:
52
+ raise ValueError("empty central page has a continuation")
53
+ rows = []
54
+ for item in body["chains"]:
55
+ root = token_id(item["root_token_id"], "central root")
56
+ if root <= previous_root:
57
+ raise ValueError("central roots must advance in ascending order")
58
+ previous_root = root
59
+ first = _observed_time(item["first_event_unix_seconds"])
60
+ last = _observed_time(item["last_event_unix_seconds"])
61
+ if first > last:
62
+ raise ValueError("reversed central observation times")
63
+ for key in ("participant_tenant_ids", "event_categories", "outcomes"):
64
+ if not isinstance(item[key], list) or not all(
65
+ isinstance(v, str) for v in item[key]
66
+ ):
67
+ raise ValueError(f"invalid central {key}")
68
+ for key in ("observed_token_count", "max_observed_hop"):
69
+ if type(item[key]) is not int or item[key] < 0:
70
+ raise ValueError(f"invalid central {key}")
71
+ rows.append(
72
+ {
73
+ "root_token_id": root,
74
+ "task_refs": [],
75
+ "first_observed": first,
76
+ "last_observed": last,
77
+ "sources": "control-plane",
78
+ "evidence_sources": ["control-plane"],
79
+ "central_observations": item,
80
+ }
81
+ )
82
+ return window, rows, more, cursor, previous_root
83
+ except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
84
+ raise InputError(f"incompatible chain-list JSON: {exc}") from exc
85
+
86
+
87
+ def list_chains(args, events) -> int:
88
+ max_pages = args.max_pages if args.max_pages is not None else 1
89
+ if not 1 <= max_pages <= 20:
90
+ raise InputError("--max-pages must be between 1 and 20")
91
+ # Resolve relative time once before any I/O; reuse it for local input and
92
+ # every page. A bare cursor obtains its bound interval from the first page.
93
+ params = query_params(
94
+ since=args.since,
95
+ start=args.from_time,
96
+ end=args.to_time,
97
+ limit=args.limit,
98
+ page_token=args.page_token,
99
+ )
100
+ window = dict(params)
101
+ central_rows = []
102
+ diagnostics = []
103
+ pages = 0
104
+ more = None
105
+ cursor = args.page_token
106
+ status = "failed"
107
+ previous_root = ""
108
+ seen_cursors = {cursor} if cursor is not None else set()
109
+ for _ in range(max_pages):
110
+ try:
111
+ body = query_chains(args.profile, params)
112
+ next_window, rows, next_more, next_cursor, last_root = _page(
113
+ body, params, previous_root
114
+ )
115
+ if next_cursor is not None and next_cursor in seen_cursors:
116
+ raise InputError("incompatible chain-list JSON: repeated continuation")
117
+ except (ValueError, TypeError) as exc:
118
+ diagnostics.append(str(exc))
119
+ status = "failed"
120
+ break
121
+ window = next_window
122
+ central_rows.extend(rows)
123
+ pages += 1
124
+ more, cursor, previous_root = next_more, next_cursor, last_root
125
+ status = "more" if more else "exhausted"
126
+ if not more:
127
+ break
128
+ seen_cursors.add(cursor)
129
+ params = {**window, "cursor": cursor}
130
+
131
+ if "from" in window and "to" in window:
132
+ rows, local_diagnostics = local_rows(
133
+ events, args.task_ref, _date(window["from"]), _date(window["to"])
134
+ )
135
+ diagnostics.extend(local_diagnostics)
136
+ else:
137
+ rows = []
138
+ diagnostics.append(
139
+ "Continuation interval unavailable; local rows cannot be selected. Retry the original interval."
140
+ )
141
+ joined = {row["root_token_id"]: row for row in rows}
142
+ for central in central_rows:
143
+ root = central["root_token_id"]
144
+ local = joined.get(root)
145
+ if local is not None:
146
+ local.update(
147
+ {
148
+ "sources": "both",
149
+ "first_observed": min(local["first_observed"], central["first_observed"]),
150
+ "last_observed": max(local["last_observed"], central["last_observed"]),
151
+ "evidence_sources": sorted([*local["evidence_sources"], "control-plane"]),
152
+ "central_observations": central["central_observations"],
153
+ }
154
+ )
155
+ elif args.task_ref is None:
156
+ joined[root] = central
157
+ coverage = {
158
+ "schema_version": 1,
159
+ "mode": "hybrid" if args.events or args.actions else "control-plane",
160
+ "from": window.get("from"),
161
+ "to": window.get("to"),
162
+ "central": {
163
+ "status": status,
164
+ "pages_fetched": pages,
165
+ "limit": int(window["limit"]) if "limit" in window else None,
166
+ "has_more": more,
167
+ "next_page_token": cursor,
168
+ "pagination": "live",
169
+ "evidence": "best_effort",
170
+ },
171
+ }
172
+ print_listing(args.output, [joined[root] for root in sorted(joined)], diagnostics, coverage)
173
+ return 4 if status == "failed" else 0
@@ -47,7 +47,15 @@ from aac_cli.agent_record import (
47
47
  read_record,
48
48
  write_record,
49
49
  )
50
- from aac_cli.config import CliConfigError, CliUsageError, cli_home, credentials_path
50
+ from aac_cli.config import (
51
+ CANONICAL_TENANT_ID_PATTERN,
52
+ JSON_OUTPUT_DEFAULT,
53
+ CliConfigError,
54
+ CliUsageError,
55
+ cli_home,
56
+ credentials_path,
57
+ validate_hosted_trust_domain,
58
+ )
51
59
  from aac_cli.config_render import configuration_summary, outbound_ca_bundle
52
60
  from aac_cli.init_cli import (
53
61
  SHARED_MATERIAL_FLAGS,
@@ -88,6 +96,18 @@ def _load(args: argparse.Namespace) -> tuple[AgentPaths, TenantPaths | None, Age
88
96
  f"[{wanted}]. The `aac agent` commands find the profile through the agent, so omit "
89
97
  f"--profile, or name an agent of profile [{wanted}] with --agent."
90
98
  )
99
+ if getattr(args, "field", None):
100
+ # Structured status retains its existing diagnostic behavior. Scalar
101
+ # consumers require a complete canonical identity before any stdout.
102
+ if not isinstance(record.tenant_id, str) or not CANONICAL_TENANT_ID_PATTERN.fullmatch(
103
+ record.tenant_id
104
+ ):
105
+ raise CliConfigError(f"agent {agent.name!r} has no valid tenant identity")
106
+ if not isinstance(record.hosted_trust_domain, str):
107
+ raise CliConfigError(f"agent {agent.name!r} has no valid hosted trust domain")
108
+ domain = validate_hosted_trust_domain(record.tenant_id, record.hosted_trust_domain)
109
+ if record.workload_spiffe_id != f"spiffe://{domain}/{record.workload_path}":
110
+ raise CliConfigError(f"agent {agent.name!r} has an invalid workload identity")
91
111
  tenant = tenant_paths(record.tenant_id) if record.tenant_id else None
92
112
  return agent, tenant, record
93
113
 
@@ -203,8 +223,19 @@ def _inventory_report(
203
223
 
204
224
 
205
225
  def cmd_agent_status(args: argparse.Namespace) -> int:
226
+ if getattr(args, "field", None) and args.remote:
227
+ raise CliUsageError("--field reports local identity only; omit --remote")
206
228
  agent, tenant, record = _load(args)
207
229
  health = assess(agent, tenant, record)
230
+ if getattr(args, "field", None):
231
+ value = getattr(record, args.field.replace("-", "_"))
232
+ if not health.healthy:
233
+ raise CliConfigError(
234
+ f"agent {agent.name!r} cannot supply {args.field}: "
235
+ f"{next_step(agent, tenant, record, health) or 'value unavailable'}"
236
+ )
237
+ print(value)
238
+ return 0
208
239
  certificates = {
209
240
  label: {
210
241
  "not_after": report.not_after,
@@ -603,14 +634,24 @@ def cmd_agent_list(args: argparse.Namespace) -> int:
603
634
  # --- parser ----------------------------------------------------------------------------
604
635
 
605
636
 
606
- def _agent_flags(parser: argparse.ArgumentParser) -> None:
637
+ def _agent_flags(parser: argparse.ArgumentParser, *, scalar: bool = False) -> None:
607
638
  parser.add_argument(
608
639
  "--agent",
609
640
  required=True,
610
641
  help="Target agent name under ~/.aac/agents/.",
611
642
  )
612
- parser.add_argument(
613
- "--output", choices=("json", "table"), default="json", help="Output mode (JSON by default)."
643
+ output = parser.add_mutually_exclusive_group() if scalar else parser
644
+ if scalar:
645
+ output.add_argument(
646
+ "--field",
647
+ choices=("tenant-id", "hosted-trust-domain", "workload-spiffe-id"),
648
+ help="Print one local identity value; fail if unavailable or the agent needs attention. Cannot combine with --output or --remote.",
649
+ )
650
+ output.add_argument(
651
+ "--output",
652
+ choices=("json", "table"),
653
+ default=JSON_OUTPUT_DEFAULT if scalar else "json",
654
+ help="Output mode (JSON by default).",
614
655
  )
615
656
  parser.add_argument(
616
657
  "--profile",
@@ -639,7 +680,7 @@ def add_agent_parser(nouns: argparse._SubParsersAction) -> None:
639
680
  "status",
640
681
  help="Report what exists, certificate expiry, permission problems and the next command.",
641
682
  )
642
- _agent_flags(status)
683
+ _agent_flags(status, scalar=True)
643
684
  status.add_argument(
644
685
  "--remote",
645
686
  action="store_true",
@@ -29,9 +29,6 @@ from aac_cli.material_cases import MATERIAL_ISSUED_HERE, MATERIAL_SUPPLIED
29
29
 
30
30
  TENANTS_DIRECTORY = "tenants"
31
31
  AGENTS_DIRECTORY = "agents"
32
- # 0.1.x kept the same material under `workspaces/`. 0.2.0 is a clean break: an
33
- # old folder is recognised only so the CLI can say so in one line (B270).
34
- LEGACY_AGENTS_DIRECTORY = "workspaces"
35
32
 
36
33
  # Where the container layout expects the mounted directories (matches the
37
34
  # public starter guide's Docker recipe so B240 can reuse its mounts).
@@ -249,21 +246,6 @@ def agent_paths(name: str, home: Path | None = None) -> AgentPaths:
249
246
  return AgentPaths(name=name, root=(home or cli_home()) / AGENTS_DIRECTORY / name)
250
247
 
251
248
 
252
- def legacy_agent_directory(name: str, home: Path | None = None) -> Path:
253
- """Where 0.1.x kept this name's material. Read only to refuse it (B270)."""
254
- return (home or cli_home()) / LEGACY_AGENTS_DIRECTORY / name
255
-
256
-
257
- def refuse_legacy_directory(name: str, home: Path | None = None) -> None:
258
- """One line, no migration: development material is disposable by design."""
259
- legacy = legacy_agent_directory(name, home)
260
- if legacy.exists():
261
- raise CliConfigError(
262
- f"{legacy} is from an older CLI and is not migrated; create a new agent with "
263
- f"`aac init --agent {name}` after deleting it, or choose another name"
264
- )
265
-
266
-
267
249
  def tenant_paths(tenant_id: str, home: Path | None = None) -> TenantPaths:
268
250
  if not CANONICAL_TENANT_ID_PATTERN.fullmatch(tenant_id):
269
251
  raise CliConfigError(f"tenant id {tenant_id!r} is not canonical; refusing to derive a path")