argus-gate 1.0.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 (126) hide show
  1. argus_gate/__init__.py +97 -0
  2. argus_gate/action.py +196 -0
  3. argus_gate/adapters/__init__.py +19 -0
  4. argus_gate/adapters/anthropic.py +89 -0
  5. argus_gate/adapters/code_mode.py +123 -0
  6. argus_gate/adapters/google.py +89 -0
  7. argus_gate/adapters/model.py +142 -0
  8. argus_gate/adapters/openai.py +105 -0
  9. argus_gate/adversarial/__init__.py +38 -0
  10. argus_gate/adversarial/__main__.py +252 -0
  11. argus_gate/adversarial/client.py +133 -0
  12. argus_gate/adversarial/harness.py +383 -0
  13. argus_gate/adversarial/improve.py +173 -0
  14. argus_gate/constraints.py +186 -0
  15. argus_gate/enterprise/__init__.py +164 -0
  16. argus_gate/enterprise/analytics.py +290 -0
  17. argus_gate/enterprise/calibration.py +304 -0
  18. argus_gate/enterprise/character.py +199 -0
  19. argus_gate/enterprise/compliance.py +174 -0
  20. argus_gate/enterprise/dlp.py +158 -0
  21. argus_gate/enterprise/fleet.py +146 -0
  22. argus_gate/enterprise/hitl.py +195 -0
  23. argus_gate/enterprise/identity.py +108 -0
  24. argus_gate/enterprise/learning.py +225 -0
  25. argus_gate/enterprise/prover.py +277 -0
  26. argus_gate/enterprise/qcb.py +640 -0
  27. argus_gate/enterprise/qcb_bundle.py +271 -0
  28. argus_gate/enterprise/regimes.py +785 -0
  29. argus_gate/enterprise/registry.py +228 -0
  30. argus_gate/enterprise/sla.py +189 -0
  31. argus_gate/enterprise/sovereign.py +266 -0
  32. argus_gate/errors.py +70 -0
  33. argus_gate/evidence.py +297 -0
  34. argus_gate/executor.py +312 -0
  35. argus_gate/gtv.py +98 -0
  36. argus_gate/guards/__init__.py +38 -0
  37. argus_gate/guards/base.py +213 -0
  38. argus_gate/guards/database.py +137 -0
  39. argus_gate/guards/delegation.py +161 -0
  40. argus_gate/guards/file.py +161 -0
  41. argus_gate/guards/http.py +123 -0
  42. argus_gate/guards/incidents.py +281 -0
  43. argus_gate/guards/sandbox.py +129 -0
  44. argus_gate/guards/shell.py +106 -0
  45. argus_gate/hooks/__init__.py +20 -0
  46. argus_gate/hooks/a2a.py +24 -0
  47. argus_gate/hooks/agents_sdk.py +34 -0
  48. argus_gate/hooks/autogen.py +27 -0
  49. argus_gate/hooks/crewai.py +45 -0
  50. argus_gate/hooks/langgraph.py +51 -0
  51. argus_gate/i18n/__init__.py +158 -0
  52. argus_gate/i18n/locales/__init__.py +7 -0
  53. argus_gate/i18n/locales/ar_gulf.py +38 -0
  54. argus_gate/i18n/locales/ar_msa.py +39 -0
  55. argus_gate/i18n/locales/bn.py +38 -0
  56. argus_gate/i18n/locales/de.py +38 -0
  57. argus_gate/i18n/locales/en.py +38 -0
  58. argus_gate/i18n/locales/es.py +38 -0
  59. argus_gate/i18n/locales/fa.py +37 -0
  60. argus_gate/i18n/locales/fr.py +38 -0
  61. argus_gate/i18n/locales/hi.py +38 -0
  62. argus_gate/i18n/locales/id.py +37 -0
  63. argus_gate/i18n/locales/it.py +38 -0
  64. argus_gate/i18n/locales/ja.py +38 -0
  65. argus_gate/i18n/locales/ko.py +38 -0
  66. argus_gate/i18n/locales/mr.py +37 -0
  67. argus_gate/i18n/locales/ms.py +37 -0
  68. argus_gate/i18n/locales/pt.py +38 -0
  69. argus_gate/i18n/locales/ru.py +38 -0
  70. argus_gate/i18n/locales/ta.py +37 -0
  71. argus_gate/i18n/locales/te.py +37 -0
  72. argus_gate/i18n/locales/th.py +38 -0
  73. argus_gate/i18n/locales/tr.py +38 -0
  74. argus_gate/i18n/locales/ur.py +38 -0
  75. argus_gate/i18n/locales/vi.py +37 -0
  76. argus_gate/i18n/locales/zh_hans.py +38 -0
  77. argus_gate/i18n/locales/zh_hant.py +38 -0
  78. argus_gate/i18n/reason_codes.py +183 -0
  79. argus_gate/i18n/review.py +524 -0
  80. argus_gate/issuer.py +109 -0
  81. argus_gate/keys.py +187 -0
  82. argus_gate/license_service/__init__.py +18 -0
  83. argus_gate/license_service/service.py +350 -0
  84. argus_gate/mcp/__init__.py +40 -0
  85. argus_gate/mcp/core.py +478 -0
  86. argus_gate/mcp/gateway.py +454 -0
  87. argus_gate/mcp/http.py +408 -0
  88. argus_gate/mcp/jsonrpc.py +166 -0
  89. argus_gate/mcp/middleware.py +456 -0
  90. argus_gate/mcp/pinning.py +252 -0
  91. argus_gate/mcp/tools.py +142 -0
  92. argus_gate/packs/__init__.py +30 -0
  93. argus_gate/packs/browser_agent/mapping.md +45 -0
  94. argus_gate/packs/browser_agent/policy +55 -0
  95. argus_gate/packs/browser_agent/tool_classes.json +11 -0
  96. argus_gate/packs/budget.py +182 -0
  97. argus_gate/packs/coding_agent/mapping.md +37 -0
  98. argus_gate/packs/coding_agent/policy +56 -0
  99. argus_gate/packs/coding_agent/tool_classes.json +19 -0
  100. argus_gate/packs/customer_support/mapping.md +36 -0
  101. argus_gate/packs/customer_support/policy +53 -0
  102. argus_gate/packs/customer_support/tool_classes.json +12 -0
  103. argus_gate/packs/data_agent/mapping.md +36 -0
  104. argus_gate/packs/data_agent/policy +56 -0
  105. argus_gate/packs/data_agent/tool_classes.json +9 -0
  106. argus_gate/packs/loader.py +60 -0
  107. argus_gate/packs/robotics_agent/mapping.md +37 -0
  108. argus_gate/packs/robotics_agent/policy +53 -0
  109. argus_gate/packs/robotics_agent/tool_classes.json +8 -0
  110. argus_gate/quota.py +216 -0
  111. argus_gate/replay.py +387 -0
  112. argus_gate/replay_window.py +132 -0
  113. argus_gate/revocation.py +183 -0
  114. argus_gate/rust_evaluator.py +80 -0
  115. argus_gate/rust_wrapper.py +145 -0
  116. argus_gate/scope.py +296 -0
  117. argus_gate/stress/__init__.py +1 -0
  118. argus_gate/stress/analyzer.py +207 -0
  119. argus_gate/stress/metrics.py +270 -0
  120. argus_gate/stress/suite.py +630 -0
  121. argus_gate/stress/workload.py +161 -0
  122. argus_gate/thread_pool.py +58 -0
  123. argus_gate/token.py +378 -0
  124. argus_gate-1.0.0.dist-info/METADATA +100 -0
  125. argus_gate-1.0.0.dist-info/RECORD +126 -0
  126. argus_gate-1.0.0.dist-info/WHEEL +4 -0
argus_gate/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Argus Gate: capability token enforcement for AI agents.
2
+
3
+ The package implements the token protocol, the action binding, the executor
4
+ guard, and the issuer. Policy evaluation and evidence records come from the
5
+ Argus SDK and Argus Core; this package does not re-implement them.
6
+ """
7
+
8
+ from .action import (
9
+ GateAction,
10
+ action_hash,
11
+ canonical_action_bytes,
12
+ proposal_bytes,
13
+ proposal_hash,
14
+ to_proposal,
15
+ )
16
+ from .enterprise import AgentRegistry, AgentRecord, ShadowAgentError
17
+ from .constraints import apply_constraints, decode_constraints, encode_constraints
18
+ from .errors import RejectionCode, TokenProtocolError, VerificationResult
19
+ from .executor import ExecutorGuard, ExecutorSettings
20
+ from .gtv import encode as gtv_encode
21
+ from .issuer import DEFAULT_TTL_US, MAX_TTL_US, TokenIssuer
22
+ from .keys import (
23
+ ROLE_GATE_ISSUER,
24
+ ROLE_OPERATOR_AUTHORITY,
25
+ STATUS_ACTIVE,
26
+ STATUS_REVOKED,
27
+ STATUS_SUPERSEDED,
28
+ KeyEpochEntry,
29
+ KeyEpochList,
30
+ )
31
+ from .replay import ReplayStore, SQLiteReplayStore, open_replay_store
32
+ from .revocation import KIND_SEQUENCE_FROM, KIND_TOKEN_ID, RevocationEntry, RevocationSet
33
+ from .scope import Scope, scope_for
34
+ from .token import (
35
+ DECISION_APPROVE,
36
+ DECISION_MODIFY,
37
+ FLAG_HOLDER_BINDING,
38
+ TOKEN_TYPE_CAPABILITY,
39
+ TOKEN_TYPE_OPERATOR_OVERRIDE,
40
+ CapabilityToken,
41
+ now_us,
42
+ )
43
+
44
+ try:
45
+ from importlib.metadata import version as _dist_version
46
+
47
+ __version__ = _dist_version("argus-gate")
48
+ except Exception:
49
+ __version__ = "1.0.0rc1"
50
+
51
+ __all__ = [
52
+ "AgentRecord",
53
+ "AgentRegistry",
54
+ "CapabilityToken",
55
+ "ShadowAgentError",
56
+ "DECISION_APPROVE",
57
+ "DECISION_MODIFY",
58
+ "DEFAULT_TTL_US",
59
+ "ExecutorGuard",
60
+ "ExecutorSettings",
61
+ "FLAG_HOLDER_BINDING",
62
+ "GateAction",
63
+ "KeyEpochEntry",
64
+ "KeyEpochList",
65
+ "KIND_SEQUENCE_FROM",
66
+ "KIND_TOKEN_ID",
67
+ "MAX_TTL_US",
68
+ "ROLE_GATE_ISSUER",
69
+ "ROLE_OPERATOR_AUTHORITY",
70
+ "RejectionCode",
71
+ "ReplayStore",
72
+ "RevocationEntry",
73
+ "RevocationSet",
74
+ "SQLiteReplayStore",
75
+ "STATUS_ACTIVE",
76
+ "STATUS_REVOKED",
77
+ "STATUS_SUPERSEDED",
78
+ "Scope",
79
+ "TOKEN_TYPE_CAPABILITY",
80
+ "TOKEN_TYPE_OPERATOR_OVERRIDE",
81
+ "TokenIssuer",
82
+ "TokenProtocolError",
83
+ "VerificationResult",
84
+ "__version__",
85
+ "action_hash",
86
+ "apply_constraints",
87
+ "canonical_action_bytes",
88
+ "decode_constraints",
89
+ "encode_constraints",
90
+ "gtv_encode",
91
+ "now_us",
92
+ "open_replay_store",
93
+ "proposal_bytes",
94
+ "proposal_hash",
95
+ "scope_for",
96
+ "to_proposal",
97
+ ]
argus_gate/action.py ADDED
@@ -0,0 +1,196 @@
1
+ """Canonical actions and their mapping to proposals and hashes.
2
+
3
+ A Gate action is (action_class, target, arguments). Its canonical bytes are
4
+ the action class byte, the target with a uint16 length, and the arguments as
5
+ GTV. `action_hash = SHA-256(0x10 || canonical action bytes)` binds the token
6
+ to the exact action the executor will perform.
7
+
8
+ The action-to-proposal mapping produces a conformant proposal for policy
9
+ evaluation and the evidence record: the scalar leaves of the arguments value
10
+ become typed parameters, named by their RFC 6901 JSON Pointer with the leading
11
+ slash removed and slashes replaced by underscores (an alias map may override
12
+ the name). Leaves whose derived name is not a policy-language identifier are
13
+ omitted from the parameter map; they remain covered by the action hash. Nulls
14
+ and containers produce no parameters.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import struct
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Iterator, Mapping, Sequence
23
+
24
+ from argus.protocol.proposal import (
25
+ ActionClass,
26
+ Parameter,
27
+ ParameterType,
28
+ ProposedAction,
29
+ Provenance,
30
+ is_identifier,
31
+ )
32
+
33
+ from .gtv import encode as gtv_encode
34
+
35
+ __all__ = [
36
+ "ACTION_HASH_DOMAIN",
37
+ "GateAction",
38
+ "action_hash",
39
+ "canonical_action_bytes",
40
+ "leaf_parameters",
41
+ "parameter_pointers",
42
+ "proposal_bytes",
43
+ "proposal_hash",
44
+ "to_proposal",
45
+ ]
46
+
47
+ ACTION_HASH_DOMAIN = 0x10
48
+ PROPOSAL_HASH_DOMAIN = 0x02
49
+ _MAX_PARAMETERS = 128
50
+
51
+ _ZERO32 = bytes(32)
52
+ _ZERO64 = bytes(64)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GateAction:
57
+ """One agent action in canonical form.
58
+
59
+ Attributes:
60
+ action_class: An action class code (0x01 to 0x06).
61
+ target: The adapter-canonical target string.
62
+ arguments: The JSON value the executor will consume.
63
+ confidence: Model-reported confidence where the surface provides it.
64
+ timestamp: Host monotonic counter where the surface provides it.
65
+ context_hash: SHA-256 of the inference context where available.
66
+ model_identity_hash: SHA-256 of the model identifier where available.
67
+ model_weight_hash: SHA-256 of the model weights where available.
68
+ previous_attestation: Previous evidence record hash where available.
69
+ host_attestation: Host attestation signature where available.
70
+ """
71
+
72
+ action_class: int
73
+ target: str
74
+ arguments: Any
75
+ confidence: float = 0.0
76
+ timestamp: int = 0
77
+ context_hash: bytes = _ZERO32
78
+ model_identity_hash: bytes = _ZERO32
79
+ model_weight_hash: bytes = _ZERO32
80
+ previous_attestation: bytes = _ZERO32
81
+ host_attestation: bytes = _ZERO64
82
+
83
+
84
+ def canonical_action_bytes(action: GateAction) -> bytes:
85
+ """The canonical action bytes (section 7.1 of the token protocol)."""
86
+ target_bytes = action.target.encode("utf-8")
87
+ if len(target_bytes) > 0xFFFF:
88
+ raise ValueError("target exceeds the uint16 length field")
89
+ return (
90
+ struct.pack("<B", action.action_class)
91
+ + struct.pack("<H", len(target_bytes))
92
+ + target_bytes
93
+ + gtv_encode(action.arguments)
94
+ )
95
+
96
+
97
+ def action_hash(action: GateAction) -> bytes:
98
+ """SHA-256(0x10 || canonical action bytes)."""
99
+ return hashlib.sha256(bytes([ACTION_HASH_DOMAIN]) + canonical_action_bytes(action)).digest()
100
+
101
+
102
+ def _pointer_escape(token: str) -> str:
103
+ return token.replace("~", "~0").replace("/", "~1")
104
+
105
+
106
+ def _iter_leaves(value: Any, pointer: str) -> Iterator[tuple[str, Any]]:
107
+ if value is None:
108
+ return
109
+ if isinstance(value, dict):
110
+ for key in value:
111
+ if not isinstance(key, str):
112
+ raise ValueError("arguments object keys must be strings")
113
+ yield from _iter_leaves(value[key], pointer + "/" + _pointer_escape(key))
114
+ elif isinstance(value, (list, tuple)):
115
+ for index, element in enumerate(value):
116
+ yield from _iter_leaves(element, pointer + "/" + str(index))
117
+ else:
118
+ yield pointer, value
119
+
120
+
121
+ def _parameter_name(pointer: str, aliases: Mapping[str, str] | None) -> str | None:
122
+ if aliases and pointer in aliases:
123
+ return aliases[pointer]
124
+ if not pointer:
125
+ return None
126
+ name = pointer[1:].replace("/", "_")
127
+ return name if is_identifier(name) else None
128
+
129
+
130
+ def leaf_parameters(
131
+ arguments: Any, aliases: Mapping[str, str] | None = None
132
+ ) -> tuple[Parameter, ...]:
133
+ """Map the scalar leaves of an arguments value to typed parameters,
134
+ ascending by name bytes. Leaves without an identifier-safe name are
135
+ omitted; they remain covered by the action hash."""
136
+ parameters: list[Parameter] = []
137
+ seen: set[str] = set()
138
+ for pointer, value in _iter_leaves(arguments, ""):
139
+ name = _parameter_name(pointer, aliases)
140
+ if name is None or name in seen:
141
+ continue
142
+ seen.add(name)
143
+ if isinstance(value, bool):
144
+ parameter = Parameter(name=name, type_tag=ParameterType.BOOL, value=value)
145
+ elif isinstance(value, (int, float)):
146
+ parameter = Parameter(name=name, type_tag=ParameterType.FLOAT64, value=float(value))
147
+ elif isinstance(value, str):
148
+ parameter = Parameter(name=name, type_tag=ParameterType.STRING, value=value)
149
+ else:
150
+ raise ValueError(f"unsupported leaf type at {pointer!r}: {type(value).__name__}")
151
+ parameters.append(parameter)
152
+ if len(parameters) > _MAX_PARAMETERS:
153
+ raise ValueError(f"parameter map exceeds {_MAX_PARAMETERS} entries")
154
+ parameters.sort(key=lambda parameter: parameter.name.encode("utf-8"))
155
+ return tuple(parameters)
156
+
157
+
158
+ def parameter_pointers(
159
+ arguments: Any, aliases: Mapping[str, str] | None = None
160
+ ) -> dict[str, str]:
161
+ """Map parameter names back to their JSON Pointers (the inverse of the
162
+ leaf mapping), for constraint translation."""
163
+ pointers: dict[str, str] = {}
164
+ for pointer, value in _iter_leaves(arguments, ""):
165
+ name = _parameter_name(pointer, aliases)
166
+ if name is not None and name not in pointers:
167
+ pointers[name] = pointer
168
+ return pointers
169
+
170
+
171
+ def to_proposal(action: GateAction, aliases: Mapping[str, str] | None = None) -> ProposedAction:
172
+ """Build the conformant proposal for policy evaluation and evidence."""
173
+ return ProposedAction(
174
+ action_class=ActionClass(action.action_class),
175
+ target=action.target,
176
+ parameters=list(leaf_parameters(action.arguments, aliases)),
177
+ confidence=action.confidence,
178
+ timestamp=action.timestamp,
179
+ context_hash=action.context_hash,
180
+ provenance=Provenance(
181
+ model_identity_hash=action.model_identity_hash,
182
+ model_weight_hash=action.model_weight_hash,
183
+ previous_attestation=action.previous_attestation,
184
+ host_attestation=action.host_attestation,
185
+ ),
186
+ )
187
+
188
+
189
+ def proposal_bytes(action: GateAction, aliases: Mapping[str, str] | None = None) -> bytes:
190
+ """The canonical proposal encoding (EST-SPEC-012 section 6)."""
191
+ return to_proposal(action, aliases).encode()
192
+
193
+
194
+ def proposal_hash(action: GateAction, aliases: Mapping[str, str] | None = None) -> bytes:
195
+ """SHA-256(0x02 || canonical proposal encoding)."""
196
+ return to_proposal(action, aliases).proposal_hash()
@@ -0,0 +1,19 @@
1
+ """Model SDK middleware: gate tool calls from OpenAI, Anthropic, and Google.
2
+
3
+ Every tool call a model emits is evaluated against the operator policy
4
+ before it executes; an approved call proceeds carrying a single-use
5
+ capability token that the executor verifies. The adapters are transport
6
+ neutral: they parse each provider's native shapes, gate through the shared
7
+ GateCore, and return the provider's own result formats.
8
+ """
9
+
10
+ from .code_mode import CodeModeAdapter, CodeModeOutcome
11
+ from .model import ModelGate, ModelToolCall, ModelToolOutcome
12
+
13
+ __all__ = [
14
+ "CodeModeAdapter",
15
+ "CodeModeOutcome",
16
+ "ModelGate",
17
+ "ModelToolCall",
18
+ "ModelToolOutcome",
19
+ ]
@@ -0,0 +1,89 @@
1
+ """Anthropic tool-calling middleware: messages API tool_use shapes.
2
+
3
+ The adapter parses each tool_use block, gates it through the shared
4
+ ModelGate, executes approved calls, and returns tool_result blocks. A denied
5
+ call never executes and returns a typed error result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Callable
12
+
13
+ from .model import ModelGate, ModelGateError, ModelToolCall
14
+
15
+ __all__ = ["gated_messages_round", "parse_tool_use", "tool_result_block"]
16
+
17
+
18
+ def parse_tool_use(content_blocks: list) -> list[ModelToolCall]:
19
+ """Normalize tool_use blocks from a messages API response."""
20
+ return [
21
+ ModelToolCall(
22
+ provider="anthropic",
23
+ name=block["name"],
24
+ arguments=block.get("input", {}),
25
+ call_id=block.get("id"),
26
+ )
27
+ for block in content_blocks
28
+ if isinstance(block, dict) and block.get("type") == "tool_use"
29
+ ]
30
+
31
+
32
+ def tool_result_block(tool_use_id: str, content: Any, is_error: bool = False) -> dict:
33
+ return {
34
+ "type": "tool_result",
35
+ "tool_use_id": tool_use_id,
36
+ "content": content if isinstance(content, str) else json.dumps(content),
37
+ "is_error": is_error,
38
+ }
39
+
40
+
41
+ def gated_messages_round(
42
+ create_fn: Callable,
43
+ *,
44
+ gate: ModelGate,
45
+ execute_tool: Callable[[str, Any], Any],
46
+ max_rounds: int = 6,
47
+ **create_kwargs,
48
+ ) -> dict:
49
+ """Drive a gated messages API loop with a client-shaped callable.
50
+
51
+ ``create_fn`` accepts model, messages, tools, and any provider kwargs and
52
+ returns a messages-shaped dict with a content block list.
53
+ """
54
+ transcript = list(create_kwargs["messages"])
55
+ decisions: list[dict] = []
56
+ for _ in range(max_rounds):
57
+ response = create_fn(**{**create_kwargs, "messages": transcript})
58
+ content = response.get("content") or []
59
+ calls = parse_tool_use(content)
60
+ if not calls:
61
+ return {"message": response, "decisions": decisions, "transcript": transcript}
62
+ transcript.append({"role": "assistant", "content": content})
63
+ result_blocks: list[dict] = []
64
+ for call in calls:
65
+ outcome = gate.evaluate(call)
66
+ decisions.append(
67
+ {
68
+ "provider": "anthropic",
69
+ "tool": call.name,
70
+ "decision": outcome.decision,
71
+ "reason": outcome.reason,
72
+ "clause_ref": outcome.clause_ref,
73
+ }
74
+ )
75
+ if not outcome.approved:
76
+ result_blocks.append(
77
+ tool_result_block(call.call_id, outcome.denial_content(), is_error=True)
78
+ )
79
+ continue
80
+ executed, value = gate.execute(outcome, execute_tool)
81
+ if executed:
82
+ result_blocks.append(
83
+ tool_result_block(call.call_id, json.dumps({"result": value}))
84
+ )
85
+ else:
86
+ content = value if isinstance(value, dict) else {"error": "execution_failed"}
87
+ result_blocks.append(tool_result_block(call.call_id, content, is_error=True))
88
+ transcript.append({"role": "user", "content": result_blocks})
89
+ raise ModelGateError(f"exceeded max_rounds {max_rounds}")
@@ -0,0 +1,123 @@
1
+ """The Code Mode adapter: the dispatch-shim for Cloudflare/Anthropic Code Mode.
2
+
3
+ Code Mode turns tools into a generated TypeScript API and runs the model's
4
+ script in a sandbox, with intermediate data kept out of the context. The
5
+ Gate's mapping: both designs keep a per-call dispatch point, so per-call
6
+ enforcement survives unchanged.
7
+
8
+ The adapter sits between the model's generated script and the sandbox
9
+ executor. The script declares its intended actions (file_read, file_write,
10
+ network, exec) as a sequence of GrantRequests. The adapter evaluates each
11
+ request against the policy, issues a capability token per granted action,
12
+ and the sandbox executes only the granted actions. An unbounded script is
13
+ refused certification; the abstention surface is INCONCLUSIVE fail-closed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any, Callable, Optional
20
+
21
+ from ..action import GateAction
22
+ from ..errors import RejectionCode, VerificationResult
23
+ from ..guards.base import GuardOutcome
24
+ from ..guards.sandbox import GRANT_CLASSES, GrantRequest, SandboxGuard, analyze_script
25
+ from ..mcp import GateCore
26
+
27
+ __all__ = ["CodeModeAdapter", "CodeModeOutcome"]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CodeModeOutcome:
32
+ """The outcome of one code mode evaluation."""
33
+
34
+ certified: bool
35
+ grants: tuple[tuple[str, str], ...] # (kind, token_bytes_b64)
36
+ refusals: tuple[tuple[str, str], ...] # (kind, reason)
37
+ script_status: str # "certified" | "refused"
38
+
39
+
40
+ class CodeModeAdapter:
41
+ """The dispatch-shim adapter: evaluates a code mode script's intended
42
+ actions against the policy and issues capability tokens per grant."""
43
+
44
+ def __init__(
45
+ self,
46
+ core: GateCore,
47
+ sandbox: SandboxGuard,
48
+ ) -> None:
49
+ self.core = core
50
+ self.sandbox = sandbox
51
+
52
+ def evaluate_script(
53
+ self,
54
+ source: str,
55
+ requests: list[GrantRequest],
56
+ request_id_base: int = 1,
57
+ ) -> CodeModeOutcome:
58
+ """Evaluate a code mode script: certify the script, then evaluate
59
+ each intended action. Returns the grants and refusals.
60
+
61
+ The script is certified if it has no unbounded constructs (via
62
+ analyze_script). A refused script is not evaluated further; every
63
+ intended action is refused with SCRIPT_NOT_CERTIFIED.
64
+ """
65
+ analysis = analyze_script(source)
66
+ if not analysis.certified:
67
+ refusals = tuple(
68
+ (req.kind, "SCRIPT_NOT_CERTIFIED") for req in requests
69
+ )
70
+ return CodeModeOutcome(
71
+ certified=False,
72
+ grants=(),
73
+ refusals=refusals,
74
+ script_status="refused",
75
+ )
76
+
77
+ grants: list[tuple[str, str]] = []
78
+ refusals: list[tuple[str, str]] = []
79
+
80
+ for i, req in enumerate(requests):
81
+ request_id = request_id_base + i
82
+ if req.kind not in GRANT_CLASSES:
83
+ refusals.append((req.kind, f"unknown grant kind {req.kind!r}"))
84
+ continue
85
+ outcome = self.core.evaluate_call(
86
+ req.kind, req.detail, request_id
87
+ )
88
+ if outcome.approved:
89
+ grants.append((req.kind, outcome.token.encode().hex()))
90
+ else:
91
+ refusals.append((req.kind, outcome.reason or "POLICY_DENIED"))
92
+
93
+ return CodeModeOutcome(
94
+ certified=len(refusals) == 0,
95
+ grants=tuple(grants),
96
+ refusals=tuple(refusals),
97
+ script_status="certified" if len(refusals) == 0 else "refused",
98
+ )
99
+
100
+ def execute_grants(
101
+ self,
102
+ grants: tuple[tuple[str, str], ...],
103
+ run_fns: dict[str, Callable[[Any], Any]],
104
+ ) -> dict[str, GuardOutcome]:
105
+ """Execute each granted action with its capability token.
106
+
107
+ Each grant carries the hex-encoded token bytes and the action kind.
108
+ run_fns maps kind -> the function to execute with the granted detail.
109
+ Returns a dict of kind -> GuardOutcome for each executed grant.
110
+ """
111
+ results: dict[str, GuardOutcome] = {}
112
+ for kind, token_hex in grants:
113
+ token_bytes = bytes.fromhex(token_hex)
114
+ if kind not in run_fns:
115
+ continue
116
+ # Reconstruct a GrantRequest for the sandbox guard
117
+ # The detail is not carried in the grant; the caller must
118
+ # reconstruct it from the original script context.
119
+ # This is a design boundary: the adapter issues the grant,
120
+ # the caller executes it with the detail from the script.
121
+ request = GrantRequest(kind=kind, detail={}, request_id=None)
122
+ results[kind] = self.sandbox.grant(token_bytes, request, run_fns[kind])
123
+ return results
@@ -0,0 +1,89 @@
1
+ """Google tool-calling middleware: generateContent functionCall shapes.
2
+
3
+ The adapter parses each functionCall part, gates it through the shared
4
+ ModelGate, and returns functionResponse parts. Protocol-conformant with the
5
+ OpenAI and Anthropic adapters: the same policy produces the same decisions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Callable
12
+
13
+ from .model import ModelGate, ModelGateError, ModelToolCall
14
+
15
+ __all__ = ["function_response_part", "gated_generate_round", "parse_function_calls"]
16
+
17
+
18
+ def parse_function_calls(response: dict) -> list[ModelToolCall]:
19
+ """Normalize functionCall parts from a generateContent response."""
20
+ calls: list[ModelToolCall] = []
21
+ for candidate in response.get("candidates") or []:
22
+ content = candidate.get("content") or {}
23
+ for part in content.get("parts") or []:
24
+ function_call = part.get("functionCall")
25
+ if isinstance(function_call, dict) and function_call.get("name"):
26
+ calls.append(
27
+ ModelToolCall(
28
+ provider="google",
29
+ name=function_call["name"],
30
+ arguments=function_call.get("args", {}),
31
+ call_id=function_call.get("id"),
32
+ )
33
+ )
34
+ return calls
35
+
36
+
37
+ def function_response_part(name: str, result: Any) -> dict:
38
+ return {
39
+ "functionResponse": {
40
+ "name": name,
41
+ "response": result if isinstance(result, dict) else {"result": result},
42
+ }
43
+ }
44
+
45
+
46
+ def gated_generate_round(
47
+ create_fn: Callable,
48
+ *,
49
+ gate: ModelGate,
50
+ execute_tool: Callable[[str, Any], Any],
51
+ max_rounds: int = 6,
52
+ **create_kwargs,
53
+ ) -> dict:
54
+ """Drive a gated generateContent loop with a client-shaped callable."""
55
+ contents = list(create_kwargs["contents"])
56
+ decisions: list[dict] = []
57
+ for _ in range(max_rounds):
58
+ response = create_fn(**{**create_kwargs, "contents": contents})
59
+ calls = parse_function_calls(response)
60
+ candidates = response.get("candidates") or []
61
+ model_content = (candidates[0].get("content") if candidates else {}) or {}
62
+ if not calls:
63
+ return {"message": response, "decisions": decisions, "contents": contents}
64
+ contents.append(model_content)
65
+ response_parts: list[dict] = []
66
+ for call in calls:
67
+ outcome = gate.evaluate(call)
68
+ decisions.append(
69
+ {
70
+ "provider": "google",
71
+ "tool": call.name,
72
+ "decision": outcome.decision,
73
+ "reason": outcome.reason,
74
+ "clause_ref": outcome.clause_ref,
75
+ }
76
+ )
77
+ if not outcome.approved:
78
+ response_parts.append(
79
+ function_response_part(call.name, json.loads(outcome.denial_content()))
80
+ )
81
+ continue
82
+ executed, value = gate.execute(outcome, execute_tool)
83
+ if executed:
84
+ response_parts.append(function_response_part(call.name, {"result": value}))
85
+ else:
86
+ content = value if isinstance(value, dict) else {"error": "execution_failed"}
87
+ response_parts.append(function_response_part(call.name, content))
88
+ contents.append({"role": "user", "parts": response_parts})
89
+ raise ModelGateError(f"exceeded max_rounds {max_rounds}")