agenticdome-python-sdk 1.2.40__py3-none-any.whl → 1.2.43__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenticdome-python-sdk
3
- Version: 1.2.40
3
+ Version: 1.2.43
4
4
  Summary: Official Python SDK for AgenticDome security guardrails, DLP, tool authorization, and multi-agent delegation enforcement.
5
5
  Author: AgenticDome
6
6
  License-Expression: Apache-2.0
@@ -2845,10 +2845,7 @@ Plus, for every production deployment:
2845
2845
 
2846
2846
  Use `VerifiedActionReporter` for executable tool calls, function calls, MCP operations and external side effects that should appear in **Verified Actions**. It is not intended for health checks or every policy lookup.
2847
2847
 
2848
- ```bash
2849
- export AGENTICDOME_EVIDENCE_API_BASE="https://your-control-plane.example"
2850
- export AGENTICDOME_EVIDENCE_TOKEN="<portal-token-scoped-to-evidence:write>"
2851
- ```
2848
+ Framework tool wrappers and the MCP host report lifecycle evidence automatically using the existing `AGENTICDOME_API_BASE`, `AGENTICDOME_API_KEY` and `AGENTICDOME_TENANT_ID`. No separate portal credential is required. Upgrade both the SDK and assigned runtime to use automatic reporting. Set `AGENTICDOME_EVIDENCE_ENABLED=false` to opt out.
2852
2849
 
2853
2850
  ```python
2854
2851
  from agenticdome_sdk import VerifiedActionReporter, verified_action
@@ -2865,7 +2862,9 @@ def refresh_customer_record(customer_id: str):
2865
2862
  ...
2866
2863
  ```
2867
2864
 
2868
- The reporter uses a bounded, non-blocking queue and retains hashes plus normalized lifecycle metadata only. It never sends raw arguments, results, prompts, source, or credentials. The dedicated evidence token is separate from the Runtime / SDK key: authorization stays on the assigned sidecar and evidence-delivery failure never changes its allow/deny decision. Integrations built on the shared framework `secure_tool` / `wrap_tool_handler` boundary and the MCP host/gateway wrapper report the same lifecycle automatically when these optional evidence variables are present; other custom boundaries can use the decorator above.
2865
+ The reporter uses a bounded, non-blocking queue and retains hashes plus normalized lifecycle metadata only. It never sends raw arguments, results, prompts, source, or credentials. The runtime queues accepted records in persistent Redis; its delivery worker relays them to the owning control plane using the existing per-sidecar service token. Network and storage failures remain pending for retry. Inspect `evidence.stats()` and call `evidence.flush()` before a short-lived process exits. SDK delivery failures and dropped records are counted and never change the protected operation's allow/deny decision.
2866
+
2867
+ Retained outcomes are labeled `sdk_reported`. Policy lookups alone do not prove execution, and streaming acceptance does not prove final completion. Existing `AGENTICDOME_EVIDENCE_API_BASE` plus `AGENTICDOME_EVIDENCE_TOKEN` remains an explicit legacy portal override. Automatic reporting starts with new wrapped calls; it does not backfill historical telemetry.
2869
2868
 
2870
2869
  ---
2871
2870
 
@@ -1,17 +1,17 @@
1
- agenticdome_python_sdk-1.2.40.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
2
- agenticdome_python_sdk-1.2.40.dist-info/licenses/NOTICE,sha256=obvKP6kV1Ny5R8_INFOpf0X5um9U2X43LGwQFobO9kg,550
1
+ agenticdome_python_sdk-1.2.43.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
2
+ agenticdome_python_sdk-1.2.43.dist-info/licenses/NOTICE,sha256=obvKP6kV1Ny5R8_INFOpf0X5um9U2X43LGwQFobO9kg,550
3
3
  agenticdome_sdk/__init__.py,sha256=1JY5ELhtp0zg-82xhgt6NxZ7k5CWL5e_Tm6BrsuCFXc,1102
4
- agenticdome_sdk/_framework_firewall.py,sha256=W3gy7WsK50scE7ypJrmVJhHUOBsr0zAWQ85rghqaJk4,31963
4
+ agenticdome_sdk/_framework_firewall.py,sha256=h_g2A5eqHeztTjURRNe-ZsUyFBw0oloLmHHNl3Kgt8Q,32748
5
5
  agenticdome_sdk/_mode.py,sha256=XvOgh5sW-FG8S39V483h3uLiFh8wSt14OmYXz_6eDO8,1283
6
6
  agenticdome_sdk/action_path_proof.py,sha256=5Zlghy7-LNhtRJM0Unb4NPUASzIsAC0LaWNVFR2yzzo,13497
7
7
  agenticdome_sdk/agno.py,sha256=L3yfjTBHW47xzT67a1DsjtwmvrAoYBkbxADpsr85Cp0,60766
8
8
  agenticdome_sdk/attestation.py,sha256=ij4cMff7nzLaIS0W02d2WURFcyKe72bcUlSz-dYkaLE,10310
9
9
  agenticdome_sdk/autogen.py,sha256=KP4qlIcsNXB-4ZT9D7Slc1xWEeWXRIWkc5HGW0U6k2c,29018
10
10
  agenticdome_sdk/aws_bedrock.py,sha256=2zD9j7xi0jna0sMeMf-hKZdc3Y4RPwzj6tMVOUGgIDQ,69581
11
- agenticdome_sdk/catalog_snapshot.json,sha256=OhMGmRF0oGAcCfj9XbVgX8NDc6TMOR1Mrc2l9S6qXCA,951
11
+ agenticdome_sdk/catalog_snapshot.json,sha256=cYOcI2eG8pj5-h_VDZs5Ve3CtpaIHjZ9dhr-lHF-Lp8,955
12
12
  agenticdome_sdk/claude.py,sha256=XeY9AUu61SYcJjQVz_AtL_WaN6X4KqP5nprEbbr4Q6A,17456
13
- agenticdome_sdk/client.py,sha256=SQk7BROrcedyHAfubGiwGNNGycq7W7L-Ncjroj1ugBg,69815
14
- agenticdome_sdk/connect.py,sha256=MY30wXYA6OaZhs__E67KcFXEy7mZMBxMSV06BnlBjRg,18766
13
+ agenticdome_sdk/client.py,sha256=cHeMnd6Jf5C4gxaPVaOXpNcAGt1r5Ht3GADmbzGlzk8,70207
14
+ agenticdome_sdk/connect.py,sha256=WOh9hEOEy6OmX4zrJEWZPsgYJUbl3PR03ukODe3WC4Q,18864
15
15
  agenticdome_sdk/copilot_ir.py,sha256=zT13tOX8xu11yrOtIPMRFVud8CQ81Gu3WxsXxyQ4XT0,17928
16
16
  agenticdome_sdk/copilot_ir_collector.cjs,sha256=oxLY8ByONo7Es4YjYiJuRz_A7IKL65Sy486UXnu-4Rs,9681
17
17
  agenticdome_sdk/crewai.py,sha256=sSJQ49O7-8kqOAD2_jrsMtMOmBOcJmFkeOv7S3-ayMs,52490
@@ -20,27 +20,29 @@ agenticdome_sdk/existing_integration.py,sha256=be6xFqe3P9d98YOydHzDeyRSbuAim4bLB
20
20
  agenticdome_sdk/framework_edits.py,sha256=4BgWHxWKEkex8_DMhjVxjovpUVkrMF7TsCZzlS94mpA,23344
21
21
  agenticdome_sdk/generic_python.py,sha256=JtXRcYEZ6_Dgxnnv4QgqeMZR8dFVr8EqFDM-XYQ-GMA,6823
22
22
  agenticdome_sdk/google_adk.py,sha256=2TA-tUhY0lOnkGcs1BN9CUEz67ebVX4HJ7mnNoRPcZs,57258
23
- agenticdome_sdk/guided_integration.py,sha256=m7EsgLrPEJhfrVMaVWO2DtjYp6S8PwpCKDYZPNbVZ2o,52259
24
- agenticdome_sdk/hook_catalog.py,sha256=XRc_MeEE3LArX1R0xfO3Vo6nZbF3U-4cOkTOMfMOZS8,29272
23
+ agenticdome_sdk/guided_integration.py,sha256=4NBCacAcwEQkVct86G8NGYwrcTNT7xaxXGZ21aPaygY,52449
24
+ agenticdome_sdk/hook_catalog.py,sha256=u72GkY76XjsV8fC1AxUz89AvYWwiOvRFeowR_Dv91hY,29291
25
25
  agenticdome_sdk/identity.py,sha256=mrymGgZSVsKJ9THRde54qGmMT7j_uZHmPJ-HtWk7PXE,4635
26
26
  agenticdome_sdk/langgraph.py,sha256=FUvF6DSA5aZP3eoBoNeldgGtPrz-dO5xcVmrPPSBEhI,84312
27
- agenticdome_sdk/lifecycle.py,sha256=9LvxmUU1LC_tMchb5uVFBnZIkVFUxOv3VH9wG_9gPtA,12855
27
+ agenticdome_sdk/lifecycle.py,sha256=obRIbJ9H90TPpfa46pZRTilg9Q8nhLTP5P2bsFbChbI,15210
28
28
  agenticdome_sdk/llamaindex.py,sha256=BHplSUIsgMi7k8tBztprzaV9iaOXiljXbdeMRax_2h8,42860
29
- agenticdome_sdk/mcp_host.py,sha256=T9ICAyz8SjOhssuhXaiEuav5fduWriDhuZ4U_e8Jh9w,69389
29
+ agenticdome_sdk/mcp_host.py,sha256=4jze8c40o-kcVbX6ZSUpKM9Psyt02KfdrMa7G09o-Z8,70662
30
30
  agenticdome_sdk/mcp_http_gateway.py,sha256=XrTgj46ZHIKJ9xS3EQPsac2C9OGUZnrOXomcLdkMk_Y,13629
31
31
  agenticdome_sdk/mcp_rehearsal_server.py,sha256=cox4XqUTL8d0gaDzqxDZcEALkaz8otjOjOqHvoUeeCo,2118
32
32
  agenticdome_sdk/mcp_verification.py,sha256=WtRhAFVPTBqGOcHNf_db1m4jX9_TO0oxP98XWk4_ISY,14551
33
33
  agenticdome_sdk/microsoft_agent_framework.py,sha256=GfIIuk0n-VhsZaTyoB7P6ilpz9bFLD95TWuX0rFSWeU,67219
34
34
  agenticdome_sdk/microsoft_ai_foundry.py,sha256=YlO968s1dbAYcaEqhqufL92NFR66GuTtngnCv7-1mYc,56911
35
- agenticdome_sdk/onboarding_cli.py,sha256=F2xwFXQUOOohhLEQ5xQ5cSsU8DC6uHhCcsuE087DQno,163604
35
+ agenticdome_sdk/onboarding_cli.py,sha256=8HK3xQ03Vp-SWZ_fZmxBXsgeZVKeUpFCZj-LmR6YbNI,169693
36
+ agenticdome_sdk/onboarding_observations.py,sha256=M4UWdpYipMGi6Nw_eP7PvMLZsAv5UpXRYK0qR28qX6o,4498
36
37
  agenticdome_sdk/openai_agents.py,sha256=0z-7FDBIG4usxnILpan6yjQHF4zXIzm9YLsh3-bW8Lw,57678
37
38
  agenticdome_sdk/outcomes.py,sha256=jadWDzQaZXrHNvpPme8lQY0jihYLCoM3s4CihAq0JRo,2507
38
39
  agenticdome_sdk/proof.py,sha256=ECKc14xSE0V1HHmcpZjfVz8-Q2H_fooah3BfdblFV8M,2878
39
40
  agenticdome_sdk/pydantic.py,sha256=WKb8X0Fgydfid-ep8E-M8Yslz2pWcCP6w7n_aBxt1rI,54961
41
+ agenticdome_sdk/report_scope.py,sha256=UVBkM6c_8ZUSdhcSyT3VMcravj5HLGrPZjlgdFanh5A,2939
40
42
  agenticdome_sdk/simulation.py,sha256=UCbZy3u9AzfxQCfY38SCV3CxGxhinV9EA4aHMPlK4mc,8426
41
43
  agenticdome_sdk/smolagents.py,sha256=wjMA1HVAQ-9wRNM38Jr9wlCpKMhF-9vxtTk6R-kwQhs,19094
42
- agenticdome_python_sdk-1.2.40.dist-info/METADATA,sha256=9ws0kO1NneHkTMz-MK7XYgZ0KYHp2ly0bo6x9R6_2vA,145642
43
- agenticdome_python_sdk-1.2.40.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
44
- agenticdome_python_sdk-1.2.40.dist-info/entry_points.txt,sha256=cRfCphkr5gLrkGIzXJEtL50HdHLajoZLbZtcMLE8NoY,113
45
- agenticdome_python_sdk-1.2.40.dist-info/top_level.txt,sha256=LZbbjXco5mqDrGwlTJpIKI5Sp6R_njzHHjExxAs9c3s,16
46
- agenticdome_python_sdk-1.2.40.dist-info/RECORD,,
44
+ agenticdome_python_sdk-1.2.43.dist-info/METADATA,sha256=a2nyawPHyq2HWXpRIwaxs7yXXKKy0_w8gKMd-NvZU9Q,146163
45
+ agenticdome_python_sdk-1.2.43.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
46
+ agenticdome_python_sdk-1.2.43.dist-info/entry_points.txt,sha256=cRfCphkr5gLrkGIzXJEtL50HdHLajoZLbZtcMLE8NoY,113
47
+ agenticdome_python_sdk-1.2.43.dist-info/top_level.txt,sha256=LZbbjXco5mqDrGwlTJpIKI5Sp6R_njzHHjExxAs9c3s,16
48
+ agenticdome_python_sdk-1.2.43.dist-info/RECORD,,
@@ -202,7 +202,8 @@ class FrameworkFirewallBase:
202
202
  timeout=config.timeout_s,
203
203
  )
204
204
  self.token_store = token_store or build_token_store(config, logger)
205
- self.lifecycle_reporter = VerifiedActionReporter.from_env(tenant_id=config.tenant_id)
205
+ self.lifecycle_reporter = VerifiedActionReporter.from_env(tenant_id=config.tenant_id, api_base=config.api_base, api_key=config.api_key,
206
+ sender=getattr(self.client, "report_action_evidence", None))
206
207
  self.denied_error = denied_error
207
208
  self.label = label
208
209
  self.logger = logger
@@ -623,14 +624,17 @@ class FrameworkFirewallBase:
623
624
  decision = await self.aauthorize_tool_call(
624
625
  session_id=sid, agent_id=aid, tool_name=tool_name, tool_args=call_args,
625
626
  tool_platform=tool_platform, text=f"[{self.label}] {aid} calls {tool_name}",
627
+ policy_context={"chain_id": lifecycle.chain_id, "action_id": lifecycle.action_id, "decision_id": lifecycle.action_id},
626
628
  )
627
629
  except Exception:
628
630
  self.lifecycle_reporter.phase(lifecycle, "authorised", "blocked")
629
631
  self.lifecycle_reporter.outcome(lifecycle, "not_attempted")
630
632
  raise
631
- self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
632
- self.lifecycle_reporter.phase(lifecycle, "admitted", "admitted")
633
633
  clean = self.sanitized_args(decision, call_args)
634
+ lifecycle = self.lifecycle_reporter.with_arguments(lifecycle, clean)
635
+ if self.verdict(decision) in {"ALLOWED", "REDACTED"} and self.reason(decision) != "fail-open":
636
+ self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
637
+ self.lifecycle_reporter.phase(lifecycle, "admitted", "admitted")
634
638
  self.lifecycle_reporter.phase(lifecycle, "attempted", "attempted")
635
639
  try:
636
640
  if kwargs:
@@ -659,14 +663,17 @@ class FrameworkFirewallBase:
659
663
  decision = self.authorize_tool_call(
660
664
  session_id=sid, agent_id=aid, tool_name=tool_name, tool_args=call_args,
661
665
  tool_platform=tool_platform, text=f"[{self.label}] {aid} calls {tool_name}",
666
+ policy_context={"chain_id": lifecycle.chain_id, "action_id": lifecycle.action_id, "decision_id": lifecycle.action_id},
662
667
  )
663
668
  except Exception:
664
669
  self.lifecycle_reporter.phase(lifecycle, "authorised", "blocked")
665
670
  self.lifecycle_reporter.outcome(lifecycle, "not_attempted")
666
671
  raise
667
- self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
668
- self.lifecycle_reporter.phase(lifecycle, "admitted", "admitted")
669
672
  clean = self.sanitized_args(decision, call_args)
673
+ lifecycle = self.lifecycle_reporter.with_arguments(lifecycle, clean)
674
+ if self.verdict(decision) in {"ALLOWED", "REDACTED"} and self.reason(decision) != "fail-open":
675
+ self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
676
+ self.lifecycle_reporter.phase(lifecycle, "admitted", "admitted")
670
677
  self.lifecycle_reporter.phase(lifecycle, "attempted", "attempted")
671
678
  try:
672
679
  if kwargs:
@@ -2,22 +2,22 @@
2
2
  "schema": "agenticdome.catalog-snapshot.v1",
3
3
  "catalog_schema": "agenticdome.hook-catalog.v1",
4
4
  "verified_at": "2026-10-02",
5
- "python_sdk_version": "1.2.40",
5
+ "python_sdk_version": "1.2.43",
6
6
  "published_packages": {
7
7
  "agenticdome-python-sdk": {
8
8
  "registry": "pypi",
9
- "version": "1.2.40",
10
- "url": "https://pypi.org/project/agenticdome-python-sdk/1.2.40/"
9
+ "version": "1.2.43",
10
+ "url": "https://pypi.org/project/agenticdome-python-sdk/1.2.43/"
11
11
  },
12
12
  "agenticdome-sdk": {
13
13
  "registry": "npm",
14
- "version": "0.6.3",
15
- "url": "https://www.npmjs.com/package/agenticdome-sdk/v/0.6.3"
14
+ "version": "0.6.14",
15
+ "url": "https://www.npmjs.com/package/agenticdome-sdk/v/0.6.14"
16
16
  },
17
17
  "agenticdome-openclaw-security": {
18
18
  "registry": "npm",
19
- "version": "1.0.2",
20
- "url": "https://www.npmjs.com/package/agenticdome-openclaw-security/v/1.0.2"
19
+ "version": "1.0.13",
20
+ "url": "https://www.npmjs.com/package/agenticdome-openclaw-security/v/1.0.13"
21
21
  },
22
22
  "openclaw": {
23
23
  "registry": "npm",
agenticdome_sdk/client.py CHANGED
@@ -375,6 +375,12 @@ class AgenticDomeClient:
375
375
  if tool_digest is not None and not re.fullmatch(r"sha256:[0-9a-f]{64}", str(tool_digest)):
376
376
  raise ValueError("'tool_digest' must be sha256 followed by 64 lowercase hexadecimal characters")
377
377
 
378
+ def report_action_evidence(self, kind: str, payload: Dict[str, Any]) -> Dict[str, Any]:
379
+ """Use the existing tenant SDK transport; runtime durably relays evidence."""
380
+ if kind not in {"events", "outcomes"}:
381
+ raise ValueError("Evidence kind must be events or outcomes")
382
+ return self._request("POST", "/mesh/evidence/" + kind, json_body=payload, timeout=5)
383
+
378
384
  def _request(
379
385
  self,
380
386
  method: str,
@@ -253,9 +253,9 @@ def run_connect(root: Path, args: Any) -> Dict[str, Any]:
253
253
  broker_setup["sdk_mode"] = effective_config.get("execution_broker_mode", "existing_sdk_or_environment_default")
254
254
  cross_part_review_required = semantic.get("workload_coverage", {}).get("cross_part_flow_proven") is False
255
255
  next_action = (
256
- "Review the generated pull request and run agenticdome verify. Then review cross-part call paths and workload-specific tests in the portal before activation."
256
+ "Review the generated pull request and run agenticdome verify. Cross-part limitations will be recorded as optional observations in .agenticdome/DISCLAIMER.md; no acknowledgement is required solely for them."
257
257
  if args.open_pr else
258
- "Review the generated patch, apply it on a branch, and run agenticdome verify. Then review cross-part call paths and workload-specific tests in the portal before activation."
258
+ "Review the generated patch, apply it on a branch, and run agenticdome verify. Cross-part limitations will be recorded as optional observations in .agenticdome/DISCLAIMER.md; no acknowledgement is required solely for them."
259
259
  ) if cross_part_review_required else (
260
260
  "Review the generated pull request and run agenticdome verify."
261
261
  if args.open_pr else "Review the generated patch, apply it on a branch, then run agenticdome verify."
@@ -490,9 +490,10 @@ def preview(root: Path, plan: Optional[Dict[str, Any]] = None, *, target: str =
490
490
  + ", A2A " + str(existing.get("a2a_call_candidates", 0))
491
491
  + ", MCP " + str(existing_surfaces.get("mcp", 0))
492
492
  + " (categories may overlap; no customer action was exercised)",
493
- "- Action-required semantic findings: " + str(semantic_gate.get("action_required", "not assessed")),
494
- "- Review-required semantic findings: " + str(semantic_gate.get("review_required", "not assessed")),
493
+ "- Raw semantic action candidates (lower severity may be optional): " + str(semantic_gate.get("action_required", "not assessed")),
494
+ "- Optional review observations: " + str(semantic_gate.get("review_required", "not assessed")),
495
495
  "",
496
+ "Optional observations are written to .agenticdome/DISCLAIMER.md on verification; high-severity and required protection/test failures remain blocking.", "",
496
497
  "## Framework hooks to attach or verify", "",
497
498
  ]
498
499
  for recommendation in plan.get("hook_recommendations", []):
@@ -325,7 +325,7 @@ FRAMEWORK_HOOK_CATALOG: Dict[str, Dict[str, Any]] = {
325
325
  "adapter_module": "agenticdome-openclaw-security",
326
326
  "adapter_class": "OpenClawFirewall",
327
327
  "attachment_methods": ["protectedExecute", "sanitizeOutput"],
328
- "native_hooks": ["before_agent_run", "before_tool_call", "tool_result_persist"],
328
+ "native_hooks": ["before_agent_run", "before_tool_call", "after_tool_call", "tool_result_persist"],
329
329
  "docs": "agenticdome-openclaw-security package README",
330
330
  },
331
331
  "mcp-ts": {
@@ -1,7 +1,7 @@
1
1
  """Privacy-bounded Verified Action lifecycle reporting.
2
2
 
3
3
  The runtime authorization path remains authoritative. This module reports only
4
- minimized lifecycle evidence through a separately scoped portal credential and
4
+ minimized lifecycle evidence through the existing tenant runtime credential and
5
5
  never sends tool arguments, prompts, responses, credentials, or source code.
6
6
  """
7
7
 
@@ -15,9 +15,11 @@ import logging
15
15
  import os
16
16
  import queue
17
17
  import threading
18
+ import time
19
+ import urllib.error
18
20
  import urllib.request
19
21
  import uuid
20
- from dataclasses import dataclass
22
+ from dataclasses import dataclass, replace
21
23
  from datetime import datetime, timezone
22
24
  from typing import Any, Callable, Dict, Optional, TypeVar
23
25
 
@@ -72,33 +74,46 @@ class VerifiedActionDenied(RuntimeError):
72
74
  class VerifiedActionReporter:
73
75
  """Non-blocking, bounded reporter for one tenant's Verified Actions.
74
76
 
75
- Configure ``AGENTICDOME_EVIDENCE_API_BASE`` and a portal token containing
76
- only ``evidence:write``. Missing evidence credentials disable reporting;
77
- they never weaken or bypass the primary AgenticDome authorization call.
77
+ SDK credentials enable reporting to the assigned runtime by default.
78
+ Explicit portal evidence credentials remain supported for existing callers.
78
79
  """
79
80
 
80
- def __init__(self, portal: str = "", access_token: str = "", *, tenant_id: str = "", timeout: float = 5.0, queue_size: int = 256) -> None:
81
+ def __init__(self, portal: str = "", access_token: str = "", *, tenant_id: str = "", timeout: float = 5.0, queue_size: int = 256,
82
+ runtime_base: str = "", api_key: str = "", sender: Optional[Callable[..., Any]] = None) -> None:
81
83
  self.portal = portal.rstrip("/")
82
84
  self.access_token = access_token.strip()
83
85
  self.tenant_id = tenant_id.strip()
86
+ self.runtime_base = runtime_base.rstrip("/")
87
+ self.api_key = api_key.strip()
88
+ self.sender = sender
89
+ self.runtime_mode = not (self.portal and self.access_token)
84
90
  self.timeout = max(1.0, min(float(timeout), 30.0))
85
91
  self._queue: "queue.Queue[tuple[str, Dict[str, Any]] | None]" = queue.Queue(maxsize=max(16, min(int(queue_size), 4096)))
86
92
  self._worker: Optional[threading.Thread] = None
87
93
  self._lock = threading.Lock()
94
+ self._stats = {"queued": 0, "delivered": 0, "failed": 0, "dropped": 0}
88
95
 
89
96
  @classmethod
90
- def from_env(cls, *, tenant_id: str = "") -> "VerifiedActionReporter":
97
+ def from_env(cls, *, tenant_id: str = "", api_base: str = "", api_key: str = "", sender: Optional[Callable[..., Any]] = None) -> "VerifiedActionReporter":
98
+ if os.getenv("AGENTICDOME_EVIDENCE_ENABLED", "true").lower() in {"0", "false", "off", "no"}:
99
+ return cls()
91
100
  return cls(
92
101
  os.getenv("AGENTICDOME_EVIDENCE_API_BASE", ""),
93
102
  os.getenv("AGENTICDOME_EVIDENCE_TOKEN", ""),
94
103
  tenant_id=tenant_id or os.getenv("AGENTICDOME_TENANT_ID", ""),
95
104
  timeout=float(os.getenv("AGENTICDOME_EVIDENCE_TIMEOUT_S", "5") or "5"),
96
105
  queue_size=int(os.getenv("AGENTICDOME_EVIDENCE_QUEUE_SIZE", "256") or "256"),
106
+ runtime_base=api_base or os.getenv("AGENTICDOME_API_BASE", ""),
107
+ api_key=api_key or os.getenv("AGENTICDOME_API_KEY", ""), sender=sender,
97
108
  )
98
109
 
99
110
  @property
100
111
  def enabled(self) -> bool:
101
- return bool(self.portal and self.access_token and self.tenant_id)
112
+ return bool(self.tenant_id and ((self.portal and self.access_token) or
113
+ (self.runtime_base.startswith(("https://", "http://")) and self.api_key)))
114
+
115
+ def stats(self) -> Dict[str, Any]:
116
+ return dict(self._stats, pending=self._queue.unfinished_tasks, enabled=self.enabled, transport="runtime" if self.runtime_mode else "portal")
102
117
 
103
118
  def new_context(self, *, operation_type: str, tool_name: Optional[str] = None, tool_version: Optional[str] = None,
104
119
  arguments: Any = None, destination: Optional[str] = None, chain_id: Optional[str] = None,
@@ -141,6 +156,10 @@ class VerifiedActionReporter:
141
156
  }
142
157
  return self._enqueue("/api/agentguard/verified-actions/events", payload)
143
158
 
159
+ def with_arguments(self, context: VerifiedActionContext, arguments: Any) -> VerifiedActionContext:
160
+ """Keep correlation IDs while hashing the arguments actually admitted for execution."""
161
+ return replace(context, arguments_sha256=_digest(arguments))
162
+
144
163
  def outcome(self, context: VerifiedActionContext, outcome_class: str, *, side_effect_reference: Optional[str] = None) -> bool:
145
164
  allowed = {"not_attempted", "rejected", "accepted", "succeeded", "partially_succeeded", "failed", "rolled_back", "unknown"}
146
165
  outcome = _token(outcome_class, allowed, "unknown")
@@ -150,8 +169,8 @@ class VerifiedActionReporter:
150
169
  "chain_id": context.chain_id, "action_id": context.action_id,
151
170
  "jti": "sdk_" + uuid.uuid4().hex, "outcome_class": outcome,
152
171
  "assurance_level": "sdk_reported", "authorised_action_sha256": context.arguments_sha256,
153
- "observed_action_sha256": context.arguments_sha256, "destination_sha256": context.destination_sha256,
154
- "side_effect_ref_sha256": _digest(side_effect_reference), "attempted_at": now, "completed_at": now,
172
+ "observed_action_sha256": None if outcome == "not_attempted" else context.arguments_sha256, "destination_sha256": context.destination_sha256,
173
+ "side_effect_ref_sha256": _digest(side_effect_reference), "completed_at": now,
155
174
  }
156
175
  return self._enqueue("/api/agentguard/verified-actions/outcomes", payload)
157
176
 
@@ -162,7 +181,7 @@ class VerifiedActionReporter:
162
181
  marker = {"_flush_event": completed}
163
182
  if not self._enqueue("", marker):
164
183
  return False
165
- return completed.wait(max(0.1, timeout))
184
+ return completed.wait(max(0.1, timeout)) and self._stats["failed"] == 0
166
185
 
167
186
  def _enqueue(self, path: str, payload: Dict[str, Any]) -> bool:
168
187
  if not self.enabled:
@@ -170,8 +189,10 @@ class VerifiedActionReporter:
170
189
  self._ensure_worker()
171
190
  try:
172
191
  self._queue.put_nowait((path, payload))
192
+ if path:self._stats["queued"] += 1
173
193
  return True
174
194
  except queue.Full:
195
+ self._stats["dropped"] += 1
175
196
  logger.warning("Verified Action evidence queue is full; authorization behavior is unchanged.")
176
197
  return False
177
198
 
@@ -193,14 +214,27 @@ class VerifiedActionReporter:
193
214
  if isinstance(marker, threading.Event):
194
215
  marker.set()
195
216
  continue
196
- request = urllib.request.Request(
197
- self.portal + path, data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), method="POST",
198
- headers={"Authorization": "Bearer " + self.access_token, "Content-Type": "application/json", "Accept": "application/json"},
199
- )
200
- with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 - configured AgenticDome portal
201
- response.read(1)
217
+ for attempt in range(3):
218
+ try:
219
+ if self.runtime_mode and self.sender:
220
+ self.sender(path.rsplit("/", 1)[-1], payload)
221
+ else:
222
+ endpoint = (self.runtime_base + "/mesh/evidence/" + path.rsplit("/", 1)[-1]) if self.runtime_mode else self.portal + path
223
+ headers = {"Content-Type": "application/json", "Accept": "application/json", "X-Tenant-Id": self.tenant_id}
224
+ headers.update({"X-API-Key": self.api_key} if self.runtime_mode else {"Authorization": "Bearer " + self.access_token})
225
+ request = urllib.request.Request(endpoint, data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), method="POST", headers=headers)
226
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
227
+ response.read(1)
228
+ self._stats["delivered"] += 1
229
+ break
230
+ except Exception as exc:
231
+ status = getattr(exc, "code", getattr(exc, "status_code", None))
232
+ if attempt == 2 or status is not None and status not in {429, 500, 502, 503, 504}:
233
+ raise
234
+ time.sleep(.1 * (2 ** attempt))
202
235
  except Exception as exc: # evidence must never alter the protected operation
203
- logger.warning("Verified Action evidence delivery failed; authorization behavior is unchanged: %s", exc)
236
+ self._stats["failed"] += 1
237
+ logger.warning("Verified Action evidence delivery failed; authorization behavior is unchanged (%s)", type(exc).__name__)
204
238
  finally:
205
239
  self._queue.task_done()
206
240
 
@@ -13,6 +13,7 @@ import time
13
13
  import uuid
14
14
  import re
15
15
  from collections import defaultdict, deque
16
+ from contextvars import ContextVar
16
17
  from dataclasses import dataclass
17
18
  from datetime import date
18
19
  from inspect import isawaitable
@@ -23,6 +24,8 @@ from .client import AgenticDomeClient
23
24
  from ._mode import credentials_or_local_sim
24
25
  from .lifecycle import VerifiedActionReporter
25
26
 
27
+ _lifecycle_decision: ContextVar[Optional[Dict[str, Any]]] = ContextVar("agenticdome_mcp_lifecycle_decision", default=None)
28
+
26
29
  try:
27
30
  from .exceptions import AgenticDomeHTTPError
28
31
  except Exception: # pragma: no cover - compatibility with older package layouts
@@ -375,7 +378,8 @@ class AgenticDomeMCPHostFirewall:
375
378
  timeout=self.config.timeout_s,
376
379
  )
377
380
  self.token_store = token_store or _build_token_store(self.config)
378
- self.lifecycle_reporter = lifecycle_reporter or VerifiedActionReporter.from_env(tenant_id=self.config.tenant_id)
381
+ self.lifecycle_reporter = lifecycle_reporter or VerifiedActionReporter.from_env(tenant_id=self.config.tenant_id,
382
+ api_base=self.config.api_base, api_key=self.config.api_key, sender=getattr(self.client, "report_action_evidence", None))
379
383
  self.provider_action_rules = dict(provider_action_rules or {})
380
384
  self.resolve_provider_facts = resolve_provider_facts
381
385
  self._rate_lock = Lock()
@@ -975,6 +979,9 @@ class AgenticDomeMCPHostFirewall:
975
979
  )
976
980
 
977
981
  async def _handle_blocking_decision(self, *, decision: Dict[str, Any], rid: Any, context: Dict[str, Any], method: str, operation: str) -> Optional[Dict[str, Any]]:
982
+ evidence = _lifecycle_decision.get()
983
+ if evidence is not None:
984
+ evidence.update(verdict=self._verdict(decision), reason=self._reason(decision))
978
985
  verdict = self._verdict(decision)
979
986
  if verdict == "ALLOWED" or (verdict == "REDACTED" and self._sanitized_args(decision, explicit_only=True) is not None):
980
987
  return None
@@ -1433,7 +1440,7 @@ class AgenticDomeMCPHostFirewall:
1433
1440
  lifecycle = self.lifecycle_reporter.new_context(
1434
1441
  operation_type="mcp_operation", tool_name=tool_name, arguments=arguments,
1435
1442
  destination=self.config.mcp_server_url or self.config.mcp_server_id,
1436
- chain_id=self._safe_str(local_context.get("verified_chain_id") or local_context.get("action_chain_id")) or None,
1443
+ chain_id=self._safe_str(local_context.get("verified_chain_id") or local_context.get("action_chain_id") or local_context.get("chain_id")) or None,
1437
1444
  action_id=self._safe_str(local_context.get("verified_action_id") or local_context.get("action_id")) or None,
1438
1445
  parent_action_id=self._safe_str(local_context.get("parent_action_id")) or None,
1439
1446
  initiator_type="human" if self._safe_str(local_context.get("user_id")) else "agent",
@@ -1441,13 +1448,25 @@ class AgenticDomeMCPHostFirewall:
1441
1448
  )
1442
1449
  self.lifecycle_reporter.phase(lifecycle, "requested", "requested")
1443
1450
 
1444
- gated = await self.preflight_request(mcp_request=mcp_request, context=context)
1451
+ local_context.update(chain_id=lifecycle.chain_id, action_id=lifecycle.action_id, decision_id=lifecycle.action_id)
1452
+ decision_evidence: Dict[str, Any] = {}
1453
+ decision_token = _lifecycle_decision.set(decision_evidence)
1454
+ try:
1455
+ gated = await self.preflight_request(mcp_request=mcp_request, context=local_context)
1456
+ except Exception:
1457
+ self.lifecycle_reporter.outcome(lifecycle, "not_attempted")
1458
+ raise
1459
+ finally:
1460
+ _lifecycle_decision.reset(decision_token)
1445
1461
  if isinstance(gated, dict) and "error" in gated:
1446
1462
  self.lifecycle_reporter.phase(lifecycle, "authorised", "blocked")
1447
1463
  self.lifecycle_reporter.outcome(lifecycle, "not_attempted")
1448
1464
  return gated
1449
1465
 
1450
- self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
1466
+ admitted_arguments = self._extract_tool_call(gated)[1] if method == "tools/call" else self._params(gated)
1467
+ lifecycle = self.lifecycle_reporter.with_arguments(lifecycle, admitted_arguments)
1468
+ if decision_evidence.get("verdict") in {"ALLOWED", "REDACTED"} and decision_evidence.get("reason") != "fail-open":
1469
+ self.lifecycle_reporter.phase(lifecycle, "authorised", "allowed")
1451
1470
  self.lifecycle_reporter.phase(lifecycle, "admitted", "admitted")
1452
1471
  self.lifecycle_reporter.phase(lifecycle, "attempted", "attempted")
1453
1472
 
@@ -1645,6 +1645,17 @@ def integration_plan(root: Path) -> Dict[str, Any]:
1645
1645
  hook["required_actions"].append("No candidate use of " + hook["recommended_attachment"]["method"] + " was observed in this selected source; attach it at the real execution path or document the external integration.")
1646
1646
  semantic_bypasses = semantic.get("bypass_risks", []) if isinstance(semantic, dict) else []
1647
1647
  semantic_reviews = semantic.get("review_findings", []) if isinstance(semantic, dict) else []
1648
+ from .onboarding_observations import POLICY as observations_policy
1649
+ workload_coverage = semantic.get("workload_coverage", {})
1650
+ assessment_complete = (
1651
+ workload_coverage.get("complete", True) is True
1652
+ and all(item.get("severity") in {"high", "medium", "low"} for item in semantic_bypasses)
1653
+ and workload_coverage.get("portal_display_capped") is not True
1654
+ and not any(item in {"attachment_points", "bypass_risks", "review_findings"}
1655
+ for item in workload_coverage.get("result_lists_at_cap", []))
1656
+ and (not workload_coverage.get("selected_parts")
1657
+ or workload_coverage.get("selected_parts") == workload_coverage.get("analyzed_parts"))
1658
+ )
1648
1659
  return {
1649
1660
  "schema": "agenticdome.integration-plan.v1",
1650
1661
  "inspection_report_sha256": _exportable_inspection(report).get("report_sha256"),
@@ -1684,10 +1695,12 @@ def integration_plan(root: Path) -> Dict[str, Any]:
1684
1695
  "unresolved_bypasses": len(semantic_bypasses),
1685
1696
  "high_severity_bypasses": sum(1 for item in semantic_bypasses if item.get("severity") == "high"),
1686
1697
  "review_required": len(semantic_reviews),
1687
- "production_ready": not semantic_bypasses and not semantic_reviews and semantic.get("confidence") in {"high", "partial"}
1688
- and semantic.get("workload_coverage", {}).get("cross_part_flow_proven", True)
1689
- and not any(item in {"attachment_points", "bypass_risks", "review_findings"}
1690
- for item in semantic.get("workload_coverage", {}).get("result_lists_at_cap", [])),
1698
+ "review_policy": "advisory",
1699
+ "assessment_complete": assessment_complete,
1700
+ "onboarding_policy": observations_policy,
1701
+ "production_ready": not any(item.get("severity") == "high" for item in semantic_bypasses)
1702
+ and semantic.get("confidence") in {"high", "partial"}
1703
+ and assessment_complete,
1691
1704
  "cross_workload_review_required": semantic.get("workload_coverage", {}).get("cross_part_flow_proven") is False,
1692
1705
  "cross_part_edges_observed": int(semantic.get("workload_coverage", {}).get("cross_part_edges", {}).get("observed_count", 0)),
1693
1706
  },
@@ -2498,6 +2511,7 @@ def verify_project(root: Path, live: bool = False, run_tests: bool = False,
2498
2511
  *, plan: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, Any]]:
2499
2512
  from .client import AgenticDomeClient
2500
2513
  from .action_path_proof import load_action_path_proof
2514
+ from .onboarding_observations import POLICY as observations_policy, observations_summary
2501
2515
 
2502
2516
  plan = plan or integration_plan(root)
2503
2517
  config_path = _agenticdome_dir(root) / "config.json"
@@ -2548,10 +2562,18 @@ def verify_project(root: Path, live: bool = False, run_tests: bool = False,
2548
2562
  }
2549
2563
  semantic_gate = plan.get("semantic_gate", {})
2550
2564
  cross_part_review_required = bool(semantic_gate.get("cross_workload_review_required"))
2565
+ observations = observations_summary(semantic_gate)
2566
+ bypass_rows = plan.get("semantic_analysis", {}).get("bypass_risks", [])
2567
+ if not isinstance(bypass_rows, list) or any(
2568
+ not isinstance(row, dict) or row.get("severity") not in {"high", "medium", "low"}
2569
+ for row in bypass_rows
2570
+ ):
2571
+ observations["classification_complete"] = False
2551
2572
  semantic_ready = (
2552
2573
  semantic_gate.get("confidence") in {"high", "partial"}
2553
- and int(semantic_gate.get("unresolved_bypasses", 0)) == 0
2554
- and int(semantic_gate.get("review_required", 0)) == 0
2574
+ and observations["classification_complete"]
2575
+ and int(semantic_gate.get("high_severity_bypasses", 0)) == 0
2576
+ and semantic_gate.get("assessment_complete", True) is True
2555
2577
  )
2556
2578
  result = {
2557
2579
  "schema": "agenticdome.verification-result.v1",
@@ -2559,6 +2581,8 @@ def verify_project(root: Path, live: bool = False, run_tests: bool = False,
2559
2581
  "mode": "live_sidecar_fixed_payload" if live else "local_sim_fixed_payload",
2560
2582
  "source_upload": False,
2561
2583
  "framework_runtime_instantiated": False,
2584
+ "onboarding_policy": observations_policy,
2585
+ "onboarding_observations": observations,
2562
2586
  "decision_cases": outcomes,
2563
2587
  "static_coverage": plan["coverage"],
2564
2588
  "semantic_gate": {
@@ -2569,10 +2593,13 @@ def verify_project(root: Path, live: bool = False, run_tests: bool = False,
2569
2593
  "unresolved_bypasses": int(semantic_gate.get("unresolved_bypasses", 0)),
2570
2594
  "high_severity_bypasses": int(semantic_gate.get("high_severity_bypasses", 0)),
2571
2595
  "review_required": int(semantic_gate.get("review_required", 0)),
2596
+ "review_policy": "advisory",
2597
+ "review_owner": "coding_assistant_or_agenticdome",
2598
+ "assessment_complete": semantic_gate.get("assessment_complete", True) is True,
2572
2599
  "passed": semantic_ready,
2573
2600
  "cross_part_review_required": cross_part_review_required,
2574
2601
  "cross_part_edges_observed": int(semantic_gate.get("cross_part_edges_observed", 0)),
2575
- "production_ready": semantic_ready and not cross_part_review_required,
2602
+ "production_ready": semantic_ready,
2576
2603
  },
2577
2604
  "application_tests": application_tests,
2578
2605
  "action_path_proof": load_action_path_proof(root),
@@ -2580,17 +2607,39 @@ def verify_project(root: Path, live: bool = False, run_tests: bool = False,
2580
2607
  and not plan["coverage"]["gaps"]
2581
2608
  and (not run_tests or semantic_ready)
2582
2609
  and (not run_tests or application_tests["passed"] is True),
2583
- "clarification": "Decision cases use fixed payloads. ready=true means local checks passed, not production approval. Split analyses also require a tenant-admin review of cross-part paths, workload-specific integration tests and live runtime evidence; dynamic paths remain outside static proof.",
2610
+ "clarification": "ready=true means required local checks passed, not production activation. Lower-severity static candidates, reviews and cross-part limitations are optional observations in .agenticdome/DISCLAIMER.md. High-severity findings, incomplete source, missing protection, failing tests and runtime proof failures remain blocking. Fixed decision cases do not execute customer actions.",
2584
2611
  }
2585
2612
  canonical = json.dumps(result, sort_keys=True, separators=(",", ":")).encode("utf-8")
2586
2613
  result["report_sha256"] = hashlib.sha256(canonical).hexdigest()
2614
+ _write_onboarding_disclaimer(root, plan, result)
2587
2615
  return (0 if result["ready"] else 2), result
2588
2616
 
2589
2617
 
2618
+ def _write_onboarding_disclaimer(root: Path, plan: Dict[str, Any], verification: Dict[str, Any],
2619
+ *, evidence_status: str = "Current local verification") -> None:
2620
+ from .onboarding_observations import FILE, disclaimer_markdown
2621
+ target = root / FILE
2622
+ if target.is_symlink() or not target.resolve().is_relative_to(root.resolve()):
2623
+ raise SystemExit("The disclaimer output is a symlink or leaves this workload; use a regular local output path.")
2624
+ # Reject symlinked ancestors even when they happen to point inside the root.
2625
+ if any(parent.is_symlink() for parent in target.parents if parent != root and root in parent.parents):
2626
+ raise SystemExit("The disclaimer output directory is a symlink; use a regular local output path.")
2627
+ _write_local_text(target, disclaimer_markdown(plan, verification, evidence_status=evidence_status))
2628
+
2629
+
2590
2630
  def _reconciliation_markdown(plan: Dict[str, Any], verification: Dict[str, Any], report_digest: str) -> str:
2591
2631
  """Give a bounded, source-free worklist without accepting AI claims as proof."""
2592
2632
  semantic = plan.get("semantic_analysis") if isinstance(plan.get("semantic_analysis"), dict) else {}
2593
2633
  gate = verification.get("semantic_gate") if isinstance(verification.get("semantic_gate"), dict) else {}
2634
+ from .onboarding_observations import POLICY as observations_policy
2635
+ bypass_rows = semantic.get("bypass_risks", [])
2636
+ known_severities = isinstance(bypass_rows, list) and all(
2637
+ isinstance(row, dict) and row.get("severity") in {"high", "medium", "low"}
2638
+ for row in bypass_rows
2639
+ )
2640
+ optional_policy = (verification.get("onboarding_policy") == observations_policy
2641
+ and verification.get("onboarding_observations", {}).get("classification_complete") is True
2642
+ and known_severities)
2594
2643
 
2595
2644
  def groups(field: str) -> List[Tuple[str, str, int, List[int]]]:
2596
2645
  grouped: Dict[Tuple[str, str], List[int]] = {}
@@ -2598,6 +2647,8 @@ def _reconciliation_markdown(plan: Dict[str, Any], verification: Dict[str, Any],
2598
2647
  for item in rows[:100]:
2599
2648
  if not isinstance(item, dict):
2600
2649
  continue
2650
+ if optional_policy and field == "bypass_risks" and item.get("severity") != "high":
2651
+ continue
2601
2652
  boundary = str(item.get("boundary") or "unknown")
2602
2653
  if boundary not in {"prompt_ingress", "tool_execution", "output_egress"}:
2603
2654
  boundary = "unknown"
@@ -2627,11 +2678,16 @@ def _reconciliation_markdown(plan: Dict[str, Any], verification: Dict[str, Any],
2627
2678
  "This is a local, source-free worklist for the current validation run. It is not a bypass waiver, a code patch, or live tenant proof.",
2628
2679
  "The AI-authored AgenticDome_Integration.md remains an assertion until these checks and real-path tests pass.", "",
2629
2680
  f"- AI report SHA-256: `{report_digest}`",
2630
- f"- Action-required candidates: {int(gate.get('unresolved_bypasses', 0))}",
2631
- f"- Review-required internal or indirect paths: {int(gate.get('review_required', 0))}",
2681
+ f"- Blocking findings: {int(gate.get('high_severity_bypasses' if optional_policy else 'unresolved_bypasses', 0))}",
2682
+ f"- Advisory internal or indirect paths: {int(gate.get('review_required', 0))} (owner: coding assistant or AgenticDome; not a local readiness blocker)",
2632
2683
  f"- Semantic confidence: {gate.get('confidence', 'unavailable')}", "",
2684
+ "## Who does what", "",
2685
+ "The coding assistant traces grouped code candidates and runs real-path tests. The customer reviews the change summary, intended routes and deployment location—not scanner line numbers.",
2686
+ "Lower-severity candidates and review notes are optional tenant observations in .agenticdome/DISCLAIMER.md. They do not require an acknowledgement or AgenticDome clearance before onboarding continues." if optional_policy else "If an action-required candidate is already protected in a real-path test, supply a minimal reproduction to AgenticDome. Stop unchanged retries; do not add duplicate guards or waive the finding.",
2687
+ "Pending deployment settings and live proof belong to the service operator and are separate from optional observations.", "",
2633
2688
  ]
2634
- for field, title in (("bypass_risks", "Action required"), ("review_findings", "Trace and classify")):
2689
+ sections = (("bypass_risks", "Action required"),) if optional_policy else (("bypass_risks", "Action required"), ("review_findings", "Trace and classify"))
2690
+ for field, title in sections:
2635
2691
  lines.extend([f"## {title}", ""])
2636
2692
  found = groups(field)
2637
2693
  if not found:
@@ -2652,10 +2708,14 @@ def _reconciliation_markdown(plan: Dict[str, Any], verification: Dict[str, Any],
2652
2708
  lines.append("- The AI report itself says a relevant path was NOT ASSESSED or unprotected. Inspect and protect that path, then update the report; its zero-count rows cannot clear this scope gap.")
2653
2709
  if plan.get("coverage", {}).get("gaps"):
2654
2710
  lines.append("- Required boundary categories are missing from the inspected workload. Attach and test them before retrying.")
2655
- if gate.get("cross_part_review_required"):
2711
+ if gate.get("assessment_complete") is False or not known_severities:
2712
+ lines.append("- Source assessment or severity classification is incomplete. The coding assistant must resolve that required check; unclassified candidates are not optional observations.")
2713
+ if gate.get("cross_part_review_required") and not optional_policy:
2656
2714
  lines.append("- Analysis was split across parts; trace cross-part calls and alternate entry paths. Separate parts are not a proof of guard dominance.")
2657
2715
  if tests.get("passed") is not True:
2658
2716
  lines.append("- Workload tests did not pass or were not detected. Run the local test command directly to see its output; no test log is uploaded.")
2717
+ if report_scope.get("activation_pending"):
2718
+ lines.append("- Local source assessment and operator activation are separate. Complete the report's exact runtime handoff and safe staging proof before production activation.")
2659
2719
  lines.extend([
2660
2720
  "- Give these grouped paths to your coding assistant together with the real source. Keep the assistant's changes reviewable and update only the canonical AgenticDome_Integration.md.",
2661
2721
  "- Rerun `agenticdome validate-report --report AgenticDome_Integration.md` after code and test changes. Do not edit the JSON evidence by hand.",
@@ -2688,7 +2748,19 @@ def validate_ai_integration_report(root: Path, report_path: Path) -> Tuple[int,
2688
2748
  scope = (quoted.group(1) if quoted else scope_match.group(1).split()[0]).strip().rstrip("/")
2689
2749
  if Path(scope).name.casefold() != root.name.casefold() or ".." in Path(scope).parts:
2690
2750
  raise SystemExit("The report's Workload scope does not match this workload root. Run from the correct deployable service.")
2691
- rows = re.findall(r"^\|\s*([1-8])\.([^\r\n]*)", markdown, re.MULTILINE)
2751
+ # Numbered operator steps are not interception categories. Older reports
2752
+ # without a table heading remain readable, with the original strict counts.
2753
+ inventory_lines = []
2754
+ has_header = bool(re.search(r"^\s*\|\s*Category\s*\|", markdown, re.MULTILINE))
2755
+ in_inventory = not has_header
2756
+ for line in markdown.splitlines():
2757
+ if has_header and not line.lstrip().startswith("|"):
2758
+ in_inventory = False
2759
+ if re.match(r"^\s*\|\s*Category\s*\|", line):
2760
+ in_inventory = True
2761
+ if in_inventory:
2762
+ inventory_lines.append(line)
2763
+ rows = re.findall(r"^\s*\|\s*\*?\*?([1-8])\.([^\r\n]*)", "\n".join(inventory_lines), re.MULTILINE)
2692
2764
  if len(rows) != 8 or {number for number, _ in rows} != {str(index) for index in range(1, 9)}:
2693
2765
  raise SystemExit("The integration report needs one valid row for each of the eight interception categories.")
2694
2766
  reported_unprotected = 0
@@ -2700,7 +2772,9 @@ def validate_ai_integration_report(root: Path, report_path: Path) -> Tuple[int,
2700
2772
  if any(value > 10000 for value in (found, existing, new, unprotected, live)) or existing + new + unprotected != found or live > existing + new:
2701
2773
  raise SystemExit("The report counts do not reconcile. Correct the AI integration inventory before validation.")
2702
2774
  reported_unprotected += unprotected
2703
- scope_incomplete = bool(re.search(r"\bNOT ASSESSED\b", markdown, re.IGNORECASE))
2775
+ from .report_scope import assessment_scope
2776
+ report_scope = assessment_scope(markdown)
2777
+ scope_incomplete = report_scope["scope_incomplete"]
2704
2778
  digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest()
2705
2779
 
2706
2780
  inspection = _exportable_inspection(inspect_repository(root))
@@ -2719,6 +2793,7 @@ def validate_ai_integration_report(root: Path, report_path: Path) -> Tuple[int,
2719
2793
  local_checks_passed = exit_code == 0 and verification.get("ready") is True
2720
2794
  verification["ai_report_scope"] = {
2721
2795
  "scope_incomplete": scope_incomplete,
2796
+ "activation_pending": report_scope["activation_pending"],
2722
2797
  "reported_unprotected": reported_unprotected,
2723
2798
  }
2724
2799
  if scope_incomplete or reported_unprotected:
@@ -2739,18 +2814,25 @@ def validate_ai_integration_report(root: Path, report_path: Path) -> Tuple[int,
2739
2814
  _write_json(inspection_path, inspection)
2740
2815
  _write_json(verification_path, verification)
2741
2816
  _write_local_text(reconciliation_path, _reconciliation_markdown(plan, verification, digest))
2817
+ _write_onboarding_disclaimer(root, plan, verification)
2818
+ has_observations = int(verification.get("onboarding_observations", {}).get("total_count", 0)) > 0
2742
2819
  partial_coverage = local_checks_passed and not verification["ready"]
2743
2820
  return exit_code, {
2744
- "status": "validation_passed" if verification["ready"] else (
2821
+ "status": ("validation_passed_with_observations" if has_observations else "validation_passed") if verification["ready"] else (
2745
2822
  "partial_coverage" if partial_coverage else "validation_needs_attention"
2746
2823
  ),
2747
2824
  "inspection": str(inspection_path.relative_to(root)),
2748
2825
  "verification": str(verification_path.relative_to(root)),
2749
2826
  "reconciliation": str(reconciliation_path.relative_to(root)),
2827
+ "disclaimer": ".agenticdome/DISCLAIMER.md",
2828
+ "optional_observations": int(verification.get("onboarding_observations", {}).get("total_count", 0)),
2750
2829
  "ai_integration_report_sha256": digest,
2751
2830
  "ready": verification["ready"],
2831
+ "local_status": ("passed_with_observations" if has_observations else "passed") if verification["ready"] else "needs_code_or_test_fix",
2832
+ "activation_status": "pending_operator_checks",
2833
+ "review_owner": "coding_assistant_or_agenticdome",
2752
2834
  "next_action": (
2753
- "Import both JSON files in Onboarding; then run the assigned-runtime check and test a real action path. No customer action executes in these fixed decision cases."
2835
+ "Import both JSON files and continue tenant verification. Optional observations are in .agenticdome/DISCLAIMER.md; investigating them is the tenant's choice. Prove a safe real action before production activation."
2754
2836
  if verification["ready"] else (
2755
2837
  "Import both JSON files as partial evidence and run a tenant-policy diagnostic if connected. "
2756
2838
  "Open .agenticdome/RECONCILIATION.md, assess or protect the remaining paths, then rerun validate-report. "
@@ -0,0 +1,74 @@
1
+ """Optional static observations, separate from runtime enforcement and proof."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ POLICY = "pass_with_observations_v1"
8
+ SCHEMA = "agenticdome.onboarding-observations.v1"
9
+ FILE = ".agenticdome/DISCLAIMER.md"
10
+
11
+
12
+ def observations_summary(gate):
13
+ names = ("action_required", "unresolved_bypasses", "review_required", "high_severity_bypasses")
14
+ values = {name: gate.get(name, 0) for name in names}
15
+ valid = "high_severity_bypasses" in gate and all(
16
+ isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 10000
17
+ for value in values.values()
18
+ )
19
+ counts = {name: value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 10000 else 0
20
+ for name, value in values.items()}
21
+ actions = max(counts["action_required"], counts["unresolved_bypasses"])
22
+ high = counts["high_severity_bypasses"]
23
+ valid = valid and high <= actions
24
+ candidates = max(0, actions - high)
25
+ cross_part = bool(gate.get("cross_part_review_required") or gate.get("cross_workload_review_required"))
26
+ return {
27
+ "schema": SCHEMA, "policy": POLICY, "file": FILE,
28
+ "classification_complete": valid,
29
+ "candidate_count": candidates, "review_count": counts["review_required"],
30
+ "cross_part_note": cross_part,
31
+ "total_count": candidates + counts["review_required"] + int(cross_part),
32
+ }
33
+
34
+
35
+ def disclaimer_markdown(plan, verification, *, evidence_status="Current local verification"):
36
+ gate = verification.get("semantic_gate", {})
37
+ observations = verification.get("onboarding_observations") or observations_summary(gate)
38
+ semantic = plan.get("semantic_analysis", {})
39
+ lines = [
40
+ "# AgenticDome onboarding observations and disclaimer", "",
41
+ f"Evidence status: {evidence_status}", "",
42
+ "These observations are optional. The tenant may investigate or improve these paths later; no acknowledgement or AgenticDome clearance is required to continue onboarding solely because of these observations.",
43
+ "Passing local checks does not certify every execution branch or activate production. High-severity bypass findings, missing protection, uninspected source, failed tests and runtime/tenant proof failures remain blocking. Runtime BLOCKED decisions, unknown verdicts and policy failures still stop execution.", "",
44
+ "| Observation | Count | Tenant action |",
45
+ "|---|---:|---|",
46
+ f"| Lower-severity analyzer candidates | {observations['candidate_count'] if observations['classification_complete'] else 0} | Optional investigation |",
47
+ f"| Review-only paths | {observations['review_count']} | Optional investigation |",
48
+ f"| Cross-part flow not automatically proven | {int(observations['cross_part_note'])} | Optional additional path tests |", "",
49
+ "## Optional locations", "",
50
+ ]
51
+ if not observations["classification_complete"]:
52
+ lines.extend(["Severity classification is incomplete. Unclassified candidates remain in RECONCILIATION.md for the coding assistant to resolve; they are not optional observations.", ""])
53
+ for field in ("bypass_risks", "review_findings"):
54
+ rows = semantic.get(field, [])
55
+ if not isinstance(rows, list):
56
+ continue
57
+ for item in rows[:100]:
58
+ if not isinstance(item, dict) or (field == "bypass_risks" and (
59
+ not observations["classification_complete"] or item.get("severity") not in {"medium", "low"}
60
+ )):
61
+ continue
62
+ raw = str(item.get("path") or "")[:500].replace("\\", "/")
63
+ if not raw or raw.startswith("/") or ".." in Path(raw).parts:
64
+ location = "[invalid relative path]"
65
+ else:
66
+ location = re.sub(r"[^A-Za-z0-9_./ -]", "_", raw)
67
+ line = item.get("line", 1)
68
+ line = max(1, min(line, 10000000)) if isinstance(line, int) and not isinstance(line, bool) else 1
69
+ kind = "analyzer candidate" if field == "bypass_risks" else "review-only path"
70
+ lines.append(f"- `{location}:{line}` — {kind}; static metadata may not observe an existing shared or downstream guard.")
71
+ if observations["total_count"] == 0:
72
+ lines.append("No optional observations in this run.")
73
+ lines.extend(["", "This file is replaced in place on verification; it is a local note, not a protection certificate or an authorization to execute a business action.", ""])
74
+ return "\n".join(lines)
@@ -0,0 +1,58 @@
1
+ """Separate current source assessment from historical and operator handoff text.
2
+
3
+ Markdown remains an assertion; inspection, semantic bypass and test gates still
4
+ run independently. Unknown application read sites remain source gaps.
5
+ """
6
+ import re
7
+
8
+
9
+ OPERATOR_SECTIONS = {
10
+ "runtime configuration handoff", "must do steps to activate firewall",
11
+ "activation prerequisites", "production activation",
12
+ }
13
+ HISTORY_SECTIONS = {"preserved historical baseline and unique evidence", "historical evidence"}
14
+
15
+
16
+ def assessment_scope(markdown):
17
+ headings = []
18
+ source_incomplete = False
19
+ activation_pending = False
20
+ source_rows = 0
21
+ structured = False
22
+ for line in markdown.splitlines():
23
+ match = re.match(r"^\s*(#{1,6})\s+(.+?)\s*$", line)
24
+ if match:
25
+ level, title = len(match[1]), match[2].casefold()
26
+ headings = [(depth, name) for depth, name in headings if depth < level]
27
+ headings.append((level, title))
28
+ structured = structured or title == "source assessment scope"
29
+ continue
30
+ sections = {name for _, name in headings}
31
+ if sections & HISTORY_SECTIONS:
32
+ continue
33
+ cells = [cell.strip().strip("*`").strip() for cell in line.strip().strip("|").split("|")] if line.lstrip().startswith("|") else []
34
+ if "source assessment scope" in sections and cells:
35
+ if cells[0].casefold() == "runtime source paths":
36
+ source_rows += 1
37
+ source_incomplete |= len(cells) < 2 or cells[1].upper() != "ASSESSED" or bool(re.search(r"\bNOT ASSESSED\b", line, re.I))
38
+ continue
39
+ if cells[0].casefold() == "production activation":
40
+ activation_pending |= len(cells) < 2 or cells[1].upper() != "VERIFIED"
41
+ continue
42
+ # A missing code read site is a source defect, even in an operator table.
43
+ if cells and cells[0].startswith(("AGENTICDOME_", "AGENTIC_ORCHESTRATOR_")):
44
+ if len(cells) < 2 or re.search(r"NOT ASSESSED|Not found|Unknown", cells[1], re.I):
45
+ source_incomplete = True
46
+ not_assessed = bool(re.search(r"\bNOT ASSESSED\b", line, re.I))
47
+ operator_row = bool(cells and (
48
+ cells[0].startswith(("AGENTICDOME_", "AGENTIC_ORCHESTRATOR_"))
49
+ or (len(cells) == 4 and re.match(r"^[1-6]\.", cells[0]))
50
+ ))
51
+ if sections & OPERATOR_SECTIONS:
52
+ activation_pending |= (not_assessed and operator_row) or bool(re.search(r"REQUIRED BEFORE LIVE|LIVE VERIFICATION PENDING|\bPENDING\b", line, re.I))
53
+ # Arbitrary uninspected source prose cannot be hidden in handoff.
54
+ source_incomplete |= not_assessed and not operator_row
55
+ else:
56
+ source_incomplete |= not_assessed
57
+ source_incomplete |= structured and source_rows != 1
58
+ return {"scope_incomplete": bool(source_incomplete), "activation_pending": bool(activation_pending)}