stacktrace-cli 0.9.0__py3-none-any.whl → 0.10.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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/cloud/setup.sh +5 -3
- stacktrace_cli/correlate/acquire.py +7 -1
- stacktrace_cli/correlate/composition.py +46 -1
- stacktrace_cli/correlate/join.py +102 -147
- stacktrace_cli/correlate/observed.py +18 -19
- stacktrace_cli/correlate/orchestrate.py +45 -18
- stacktrace_cli/cost/baseline.py +174 -112
- stacktrace_cli/cost/component_usage.py +324 -0
- stacktrace_cli/cost/components.py +5 -5
- stacktrace_cli/cost/measures.py +8 -5
- stacktrace_cli/cost/org_baselines.py +258 -0
- stacktrace_cli/cost/reasons.py +592 -0
- stacktrace_cli/cost/rules.py +154 -630
- stacktrace_cli/cost/runs.py +44 -21
- stacktrace_cli/cost/usage.py +293 -0
- stacktrace_cli/daemon/accounts.py +269 -0
- stacktrace_cli/daemon/cli.py +46 -15
- stacktrace_cli/daemon/client.py +41 -0
- stacktrace_cli/daemon/composition.py +53 -1
- stacktrace_cli/daemon/config.py +11 -2
- stacktrace_cli/daemon/costs.py +194 -296
- stacktrace_cli/daemon/db.py +250 -0
- stacktrace_cli/daemon/fleet.py +85 -21
- stacktrace_cli/daemon/observer.py +348 -263
- stacktrace_cli/daemon/presentation.py +49 -3
- stacktrace_cli/daemon/runtime.py +153 -17
- stacktrace_cli/daemon/scan.py +1 -1
- stacktrace_cli/daemon/server.py +17 -3
- stacktrace_cli/daemon/store.py +1259 -28
- stacktrace_cli/daemon/usage.py +1126 -0
- stacktrace_cli/daemon/webhook.py +2 -2
- stacktrace_cli/detector/cache.py +4 -0
- stacktrace_cli/detector/deterministic.py +11 -12
- stacktrace_cli/detector/prompts/__init__.py +0 -5
- stacktrace_cli/detector/reasoning.py +4 -4
- stacktrace_cli/detector/rules.py +12 -15
- stacktrace_cli/detector/run.py +2 -2
- stacktrace_cli/detector/windows.py +3 -3
- stacktrace_cli/monitor/costs.py +355 -80
- stacktrace_cli/monitor/render.py +69 -25
- stacktrace_cli/monitor/server.py +14 -1
- stacktrace_cli/monitor/site/app.js +516 -135
- stacktrace_cli/monitor/site/cost.js +856 -284
- stacktrace_cli/monitor/site/fonts/OFL.txt +196 -97
- stacktrace_cli/monitor/site/fonts/playfair-display-600-italic-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/index.html +35 -11
- stacktrace_cli/monitor/site/inventory.js +5 -2
- stacktrace_cli/monitor/site/styles.css +494 -178
- stacktrace_cli/monitor/watch.py +39 -12
- stacktrace_cli/private_state.py +2 -3
- stacktrace_cli/remote/client.py +93 -1
- stacktrace_cli/remote/detect_payload.py +34 -14
- stacktrace_cli/remote/identity.py +39 -31
- stacktrace_cli/remote/redact.py +0 -7
- stacktrace_cli/remote/spool.py +8 -6
- stacktrace_cli/remote/upload_contract.py +136 -4
- stacktrace_cli/sessions/access.py +96 -0
- stacktrace_cli/sessions/protocols.py +18 -0
- stacktrace_cli/telemetry/events.py +3 -0
- {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.10.0.dist-info}/METADATA +23 -7
- {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.10.0.dist-info}/RECORD +64 -57
- {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.10.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.10.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/cloud/setup.sh
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
# home, because that home does not exist until this run creates it.
|
|
39
39
|
#
|
|
40
40
|
# Options (all optional):
|
|
41
|
-
# --asset-id VALUE use this asset id instead of minting
|
|
42
|
-
# from STACKTRACE_ASSET_EXTERNAL_ID in the environment
|
|
41
|
+
# --asset-id VALUE use this asset id instead of minting a bare UUID; also
|
|
42
|
+
# taken from STACKTRACE_ASSET_EXTERNAL_ID in the environment
|
|
43
43
|
# --root DIR treat DIR as / (for testing)
|
|
44
44
|
#
|
|
45
45
|
# The remote token and API URL, and the Slack webhook URL, are read by the boot
|
|
@@ -82,7 +82,9 @@ _uuid() {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
DISPLAY_PREFIX="cloud"
|
|
85
|
-
|
|
85
|
+
# A bare UUID, like every key `remote/identity.py` mints (ADR-0105). The kind of
|
|
86
|
+
# box is the label's business (asset-display-prefix below), not the key's.
|
|
87
|
+
[ -n "$ASSET_ID" ] || ASSET_ID="$(_uuid)"
|
|
86
88
|
# remote/identity.py:_is_identifier rejects whitespace, non-printable
|
|
87
89
|
# characters, and anything over 255 characters. A value this accepts but the
|
|
88
90
|
# runtime rejects is worse than no file: every session reads it, rejects it,
|
|
@@ -39,6 +39,7 @@ from typing import Any
|
|
|
39
39
|
from stacktrace_cli.correlate.composition import (
|
|
40
40
|
Advisory,
|
|
41
41
|
Composition,
|
|
42
|
+
composable,
|
|
42
43
|
composition_from_bom,
|
|
43
44
|
reject_malformed_shape,
|
|
44
45
|
root_property,
|
|
@@ -234,8 +235,13 @@ def build_bom(
|
|
|
234
235
|
|
|
235
236
|
Returns None rather than raising when nothing was produced: a machine can run
|
|
236
237
|
one agent kind and not another, which is a fact about the machine rather than
|
|
237
|
-
a failure to look.
|
|
238
|
+
a failure to look. A kind OpenACA does not know is the same answer for the
|
|
239
|
+
same reason -- sessions can come from an agent whose setup OpenACA cannot
|
|
240
|
+
describe yet, and those sessions resolve as having no composition rather than
|
|
241
|
+
failing every other kind's run.
|
|
238
242
|
"""
|
|
243
|
+
if not composable(agent_kind):
|
|
244
|
+
return None
|
|
239
245
|
with tempfile.TemporaryDirectory() as scratch:
|
|
240
246
|
directory = Path(scratch)
|
|
241
247
|
argv = [
|
|
@@ -24,11 +24,25 @@ so a legitimate flat BOM resolves instead of being rejected.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import json
|
|
27
28
|
from dataclasses import dataclass, field
|
|
28
29
|
from datetime import datetime
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
|
-
from openaca.core import bom_components_from_cyclonedx
|
|
32
|
+
from openaca.core import KindSelectionError, bom_components_from_cyclonedx, validate_kind_selection
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def composable(agent_kind: str) -> bool:
|
|
36
|
+
"""Whether OpenACA can describe this agent kind's setup at all.
|
|
37
|
+
|
|
38
|
+
Asked through the facade's own check rather than a list of ours, so a kind
|
|
39
|
+
OpenACA learns reaches this package with the pin and no change here.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
validate_kind_selection(agent_kind, None)
|
|
43
|
+
except KindSelectionError:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
32
46
|
|
|
33
47
|
|
|
34
48
|
@dataclass(frozen=True)
|
|
@@ -126,6 +140,12 @@ class Composition:
|
|
|
126
140
|
#: one agent as two rows. Defaulted rather than positional so it sits with
|
|
127
141
|
#: the fields a document may omit, though it belongs beside `agent_kind`.
|
|
128
142
|
agent_instance_id: str | None = None
|
|
143
|
+
#: The sub-agent types and skills the agent provides itself, as OpenACA
|
|
144
|
+
#: lists them on the BOM root (its ADR-0076). Never components: nothing
|
|
145
|
+
#: installed them, so `find` never answers with one. Empty for a BOM that
|
|
146
|
+
#: lists none, which is every BOM OpenACA wrote before it listed them.
|
|
147
|
+
builtin_agents: frozenset[str] = frozenset()
|
|
148
|
+
builtin_skills: frozenset[str] = frozenset()
|
|
129
149
|
|
|
130
150
|
def find(self, component_type: str, name: str) -> tuple[Component, ...]:
|
|
131
151
|
"""Every component of this type and name, in BOM order.
|
|
@@ -181,6 +201,8 @@ def composition_from_bom(
|
|
|
181
201
|
advisories=advisories or {},
|
|
182
202
|
advisories_checked=advisories is not None,
|
|
183
203
|
agent_instance_id=_declared_instance_id(document),
|
|
204
|
+
builtin_agents=_declared_builtins(document, _BUILTIN_AGENTS_PROPERTY),
|
|
205
|
+
builtin_skills=_declared_builtins(document, _BUILTIN_SKILLS_PROPERTY),
|
|
184
206
|
)
|
|
185
207
|
|
|
186
208
|
|
|
@@ -211,6 +233,29 @@ def _declared_instance_id(document: dict[str, Any]) -> str | None:
|
|
|
211
233
|
return declared if declared and declared.strip() else None
|
|
212
234
|
|
|
213
235
|
|
|
236
|
+
_BUILTIN_AGENTS_PROPERTY = "openaca:builtin_agents"
|
|
237
|
+
_BUILTIN_SKILLS_PROPERTY = "openaca:builtin_skills"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _declared_builtins(document: dict[str, Any], name: str) -> frozenset[str]:
|
|
241
|
+
"""A JSON array of names on the root, or nothing.
|
|
242
|
+
|
|
243
|
+
A malformed list reads as none, as a blank `openaca:agent_id` does: each
|
|
244
|
+
built-in call then reads as absent, which is visible, while rejecting the
|
|
245
|
+
document would lose every other call's answer with it.
|
|
246
|
+
"""
|
|
247
|
+
declared = root_property(document, name)
|
|
248
|
+
if declared is None:
|
|
249
|
+
return frozenset()
|
|
250
|
+
try:
|
|
251
|
+
names = json.loads(declared)
|
|
252
|
+
except json.JSONDecodeError:
|
|
253
|
+
return frozenset()
|
|
254
|
+
if not isinstance(names, list) or not all(isinstance(n, str) and n for n in names):
|
|
255
|
+
return frozenset()
|
|
256
|
+
return frozenset(names)
|
|
257
|
+
|
|
258
|
+
|
|
214
259
|
def root_property(document: dict[str, Any], name: str) -> str | None:
|
|
215
260
|
"""One `metadata.component` property's value, or `None`.
|
|
216
261
|
|
stacktrace_cli/correlate/join.py
CHANGED
|
@@ -13,7 +13,7 @@ would invent the evidence a later stage would then act on.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
|
-
from typing import Literal
|
|
16
|
+
from typing import Literal, Protocol
|
|
17
17
|
|
|
18
18
|
from stacktrace_cli.correlate.composition import Component, Composition
|
|
19
19
|
from stacktrace_cli.sessions.protocols import ToolCallLike
|
|
@@ -58,118 +58,24 @@ unresolvable by construction rather than by omission. Collapsing them reads as
|
|
|
58
58
|
"""
|
|
59
59
|
EdgeType = Literal["observed_invocation"]
|
|
60
60
|
|
|
61
|
-
#:
|
|
62
|
-
#: so
|
|
63
|
-
#:
|
|
64
|
-
#:
|
|
65
|
-
#:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"Bash",
|
|
80
|
-
"Glob",
|
|
81
|
-
"Grep",
|
|
82
|
-
"NotebookEdit",
|
|
83
|
-
# Retrieval
|
|
84
|
-
"WebFetch",
|
|
85
|
-
"WebSearch",
|
|
86
|
-
"ToolSearch",
|
|
87
|
-
# Session and planning surface
|
|
88
|
-
"TodoWrite",
|
|
89
|
-
"ExitPlanMode",
|
|
90
|
-
"EnterPlanMode",
|
|
91
|
-
"AskUserQuestion",
|
|
92
|
-
"Artifact",
|
|
93
|
-
"ReportFindings",
|
|
94
|
-
# Background work and messaging
|
|
95
|
-
"Monitor",
|
|
96
|
-
"TaskOutput",
|
|
97
|
-
"TaskStop",
|
|
98
|
-
"SendMessage",
|
|
99
|
-
"SendUserFile",
|
|
100
|
-
"ListAgents",
|
|
101
|
-
}
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
#: Subagent types an agent kind provides itself. `Agent` naming one of these is
|
|
106
|
-
#: the agent dispatching its own generic worker, not an installed component, so
|
|
107
|
-
#: it resolves to the agent root exactly as a built-in tool does. A name outside
|
|
108
|
-
#: this set is a configured subagent and joins to an `agent` component.
|
|
109
|
-
_BUILTIN_SUBAGENTS: dict[str, frozenset[str]] = {
|
|
110
|
-
"claude-code": frozenset(
|
|
111
|
-
{
|
|
112
|
-
"general-purpose",
|
|
113
|
-
"Explore",
|
|
114
|
-
"Plan",
|
|
115
|
-
"claude",
|
|
116
|
-
# Shipped inside the executable rather than installed, so no scan of
|
|
117
|
-
# the filesystem can see them; see `_BUILTIN_SKILLS`.
|
|
118
|
-
"claude-code-guide",
|
|
119
|
-
"statusline-setup",
|
|
120
|
-
"output-style-setup",
|
|
121
|
-
}
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
#: Skills an agent kind ships *inside its own executable*. Not a scanning gap:
|
|
126
|
-
#: these have no manifest, no directory and no package anywhere on the
|
|
127
|
-
#: filesystem, so no composition analysis can find them however thorough it is.
|
|
128
|
-
#: Extracted from the shipped binary rather than guessed — every name below
|
|
129
|
-
#: appears there as skill frontmatter.
|
|
130
|
-
#:
|
|
131
|
-
#: Unlike built-in tools, these are consulted only *after* the composition has
|
|
132
|
-
#: been asked and has no answer. The names are ordinary words — `plan`, `run`,
|
|
133
|
-
#: `design`, `verify` — that a user's own skill may legitimately take, and
|
|
134
|
-
#: checking them first would attribute an installed skill's call to the agent
|
|
135
|
-
#: itself. Installed always wins; this is the fallback.
|
|
136
|
-
_BUILTIN_SKILLS: dict[str, frozenset[str]] = {
|
|
137
|
-
"claude-code": frozenset(
|
|
138
|
-
{
|
|
139
|
-
"api-design",
|
|
140
|
-
"artifact-components",
|
|
141
|
-
"artifact-dashboard",
|
|
142
|
-
"artifact-data-table",
|
|
143
|
-
"artifact-design",
|
|
144
|
-
"artifact-diagramming",
|
|
145
|
-
"artifact-explainer",
|
|
146
|
-
"artifact-pr-review",
|
|
147
|
-
"artifact-report",
|
|
148
|
-
"code-reviewer",
|
|
149
|
-
"coding-standards",
|
|
150
|
-
"design",
|
|
151
|
-
"design-sync",
|
|
152
|
-
"doc",
|
|
153
|
-
"example",
|
|
154
|
-
"fix-lint",
|
|
155
|
-
"import-to-claude-code",
|
|
156
|
-
"meeting-notes",
|
|
157
|
-
"plan",
|
|
158
|
-
"plan-artifact",
|
|
159
|
-
"prototype",
|
|
160
|
-
"review-changes",
|
|
161
|
-
"run",
|
|
162
|
-
"run-skill-generator",
|
|
163
|
-
"sheet",
|
|
164
|
-
"slides",
|
|
165
|
-
"ticket-analyzer",
|
|
166
|
-
"verify",
|
|
167
|
-
"whiteboard",
|
|
168
|
-
"workshop",
|
|
169
|
-
"workshop-page",
|
|
170
|
-
}
|
|
171
|
-
)
|
|
172
|
-
}
|
|
61
|
+
#: Tool classes whose calls the agent itself makes. OpenAIDR classes every call
|
|
62
|
+
#: per kind (its ADR-0021), so this is the whole of "built-in" for any kind it
|
|
63
|
+
#: reads: a call it could not class is `None` and stays unrecognized, never
|
|
64
|
+
#: silently promoted. `delegate` and `skill` name a component, so a call in
|
|
65
|
+
#: either is resolved by what it names, and `mcp` by its server.
|
|
66
|
+
_AGENT_ACTS = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"shell",
|
|
69
|
+
"shell_control",
|
|
70
|
+
"file_read",
|
|
71
|
+
"file_search",
|
|
72
|
+
"file_write",
|
|
73
|
+
"file_edit",
|
|
74
|
+
"web_fetch",
|
|
75
|
+
"web_search",
|
|
76
|
+
"other",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
173
79
|
|
|
174
80
|
#: Component types a composition may answer a key with, beyond the key's own.
|
|
175
81
|
#:
|
|
@@ -181,12 +87,12 @@ _BUILTIN_SKILLS: dict[str, frozenset[str]] = {
|
|
|
181
87
|
#: leaves a component that is plainly installed reading as absent.
|
|
182
88
|
_EQUIVALENT_TYPES: dict[str, tuple[str, ...]] = {"skill": ("command",)}
|
|
183
89
|
|
|
184
|
-
#: Tool
|
|
185
|
-
#:
|
|
186
|
-
#:
|
|
90
|
+
#: Tool class -> (component type, the canonical argument naming it). A call
|
|
91
|
+
#: that runs a skill names it under `skill`, and one that dispatches a subagent
|
|
92
|
+
#: names it under `agent`, whatever the kind records them as.
|
|
187
93
|
_ARGUMENT_SIGNALS: dict[str, tuple[str, str]] = {
|
|
188
|
-
"
|
|
189
|
-
"
|
|
94
|
+
"skill": ("skill", "skill"),
|
|
95
|
+
"delegate": ("agent", "agent"),
|
|
190
96
|
}
|
|
191
97
|
|
|
192
98
|
|
|
@@ -220,25 +126,68 @@ class Resolution:
|
|
|
220
126
|
target: str | None
|
|
221
127
|
|
|
222
128
|
|
|
223
|
-
|
|
224
|
-
"""
|
|
225
|
-
return tool_name in _BUILTIN_TOOLS.get(agent_kind, frozenset())
|
|
129
|
+
class NamedTool(Protocol):
|
|
130
|
+
"""What deciding a built-in reads: a call, or a summary of one."""
|
|
226
131
|
|
|
132
|
+
@property
|
|
133
|
+
def tool_name(self) -> str: ...
|
|
134
|
+
@property
|
|
135
|
+
def mcp_server(self) -> str | None: ...
|
|
136
|
+
@property
|
|
137
|
+
def tool_class(self) -> str | None: ...
|
|
227
138
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
139
|
+
|
|
140
|
+
def is_builtin(call: NamedTool, agent_kind: str) -> bool:
|
|
141
|
+
"""Whether this kind's own agent is the actor for this call.
|
|
142
|
+
|
|
143
|
+
An MCP server picks its own tool names, and nothing stops one from choosing
|
|
144
|
+
`Read` or `Bash`; `call.mcp_server` is the client's own record of where the
|
|
145
|
+
call went, so a call carrying one is never the agent's.
|
|
146
|
+
"""
|
|
147
|
+
del agent_kind # The class is filled per kind upstream; nothing here varies by it.
|
|
148
|
+
if call.mcp_server:
|
|
231
149
|
return False
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
150
|
+
return call.tool_class in _AGENT_ACTS
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _requested_subagent(call: ToolCallLike) -> str | None:
|
|
154
|
+
"""The subagent a delegating call asked for, or None."""
|
|
155
|
+
if call.tool_class != "delegate":
|
|
156
|
+
return None
|
|
157
|
+
requested = call.canonical_arguments.get("agent")
|
|
158
|
+
return requested if isinstance(requested, str) and requested else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_builtin_subagent(call: ToolCallLike, composition: Composition | None) -> bool:
|
|
162
|
+
"""Whether this call dispatches a subagent type the agent provides itself.
|
|
163
|
+
|
|
164
|
+
Naming one is the agent dispatching its own generic worker, not an
|
|
165
|
+
installed component, so it resolves to the agent root exactly as a
|
|
166
|
+
built-in tool does. Asked of the composition, which lists what its kind
|
|
167
|
+
provides (OpenACA ADR-0076): a name outside that list is a configured
|
|
168
|
+
subagent and joins to an `agent` component.
|
|
169
|
+
"""
|
|
170
|
+
requested = _requested_subagent(call)
|
|
171
|
+
return (
|
|
172
|
+
requested is not None
|
|
173
|
+
and composition is not None
|
|
174
|
+
and requested in composition.builtin_agents
|
|
235
175
|
)
|
|
236
176
|
|
|
237
177
|
|
|
238
|
-
def is_builtin_skill(key: JoinKey,
|
|
239
|
-
"""Whether this key names a skill the agent
|
|
240
|
-
|
|
241
|
-
|
|
178
|
+
def is_builtin_skill(key: JoinKey, composition: Composition | None) -> bool:
|
|
179
|
+
"""Whether this key names a skill the agent ships itself.
|
|
180
|
+
|
|
181
|
+
Consulted only *after* the composition's components have no answer. The
|
|
182
|
+
names are ordinary words — `run`, `design`, `verify` — that a user's own
|
|
183
|
+
skill may legitimately take, and checking them first would attribute an
|
|
184
|
+
installed skill's call to the agent itself. Installed always wins; this is
|
|
185
|
+
the fallback.
|
|
186
|
+
"""
|
|
187
|
+
return (
|
|
188
|
+
key.component_type == "skill"
|
|
189
|
+
and composition is not None
|
|
190
|
+
and key.name in composition.builtin_skills
|
|
242
191
|
)
|
|
243
192
|
|
|
244
193
|
|
|
@@ -256,7 +205,9 @@ def candidates_for(key: JoinKey, composition: Composition) -> tuple[Component, .
|
|
|
256
205
|
return candidates
|
|
257
206
|
|
|
258
207
|
|
|
259
|
-
def builtin_category_and_name(
|
|
208
|
+
def builtin_category_and_name(
|
|
209
|
+
call: ToolCallLike, composition: Composition | None
|
|
210
|
+
) -> tuple[str, str] | None:
|
|
260
211
|
"""What a built-in resolution was, re-asked rather than read.
|
|
261
212
|
|
|
262
213
|
A built-in's `Resolution` is `key=None, candidates=(), target=root` and is
|
|
@@ -267,13 +218,12 @@ def builtin_category_and_name(call: ToolCallLike, agent_kind: str) -> tuple[str,
|
|
|
267
218
|
attribution and a finding's own subject have to give the same answer, and
|
|
268
219
|
two copies is how they would come to differ.
|
|
269
220
|
"""
|
|
270
|
-
if is_builtin_subagent(call,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if is_builtin(call.tool_name, agent_kind):
|
|
221
|
+
if is_builtin_subagent(call, composition):
|
|
222
|
+
return "subagent", str(_requested_subagent(call))
|
|
223
|
+
if is_builtin(call, composition.agent_kind if composition is not None else ""):
|
|
274
224
|
return "tool", call.tool_name
|
|
275
225
|
key = join_key(call)
|
|
276
|
-
if key is not None and is_builtin_skill(key,
|
|
226
|
+
if key is not None and is_builtin_skill(key, composition):
|
|
277
227
|
return "skill", key.name
|
|
278
228
|
return None
|
|
279
229
|
|
|
@@ -295,6 +245,14 @@ def join_key(call: ToolCallLike) -> JoinKey | None:
|
|
|
295
245
|
return JoinKey(component_type, name if call.mcp_server else _unqualified(name))
|
|
296
246
|
|
|
297
247
|
|
|
248
|
+
def listed_key(component_type: str, name: str) -> JoinKey:
|
|
249
|
+
"""The key a component the agent's own context lists by name is looked up
|
|
250
|
+
by: as a call naming it would be (`join_key`), a skill's plugin qualifier
|
|
251
|
+
dropped and a server's name kept as the client recorded it. Ask it with
|
|
252
|
+
`candidates_for`, so a skill inventoried as a command still answers."""
|
|
253
|
+
return JoinKey(component_type, name if component_type == "mcp_server" else _unqualified(name))
|
|
254
|
+
|
|
255
|
+
|
|
298
256
|
def addressed_naming(call: ToolCallLike) -> tuple[str, str] | None:
|
|
299
257
|
"""The component type this call named, and the word it named it by.
|
|
300
258
|
|
|
@@ -311,11 +269,11 @@ def addressed_naming(call: ToolCallLike) -> tuple[str, str] | None:
|
|
|
311
269
|
"""
|
|
312
270
|
if call.mcp_server:
|
|
313
271
|
return "mcp_server", call.mcp_server
|
|
314
|
-
signal = _ARGUMENT_SIGNALS.get(call.
|
|
272
|
+
signal = _ARGUMENT_SIGNALS.get(call.tool_class or "")
|
|
315
273
|
if signal is None:
|
|
316
274
|
return None
|
|
317
275
|
component_type, argument = signal
|
|
318
|
-
value =
|
|
276
|
+
value = call.canonical_arguments.get(argument)
|
|
319
277
|
if not isinstance(value, str) or not value:
|
|
320
278
|
return None
|
|
321
279
|
return component_type, value
|
|
@@ -342,13 +300,10 @@ def _unqualified(name: str) -> str:
|
|
|
342
300
|
|
|
343
301
|
def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
|
|
344
302
|
"""Resolve one call against one composition."""
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
# where the call actually went, so a call carrying one is never read as a
|
|
348
|
-
# built-in: the server key, not the borrowed name, decides what this named.
|
|
303
|
+
# A call carrying a server is never read as a built-in (`is_builtin`): the
|
|
304
|
+
# server key, not a borrowed tool name, decides what it named.
|
|
349
305
|
if not call.mcp_server and (
|
|
350
|
-
is_builtin(call
|
|
351
|
-
or is_builtin_subagent(call, composition.agent_kind)
|
|
306
|
+
is_builtin(call, composition.agent_kind) or is_builtin_subagent(call, composition)
|
|
352
307
|
):
|
|
353
308
|
# The agent is the actor. Naming a component here would attribute the
|
|
354
309
|
# call to something that did not make it.
|
|
@@ -364,7 +319,7 @@ def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
|
|
|
364
319
|
|
|
365
320
|
key = join_key(call)
|
|
366
321
|
if key is None:
|
|
367
|
-
recognized = bool(call.mcp_server) or call.
|
|
322
|
+
recognized = bool(call.mcp_server) or call.tool_class in _ARGUMENT_SIGNALS
|
|
368
323
|
return _unresolved(
|
|
369
324
|
call.span,
|
|
370
325
|
None,
|
|
@@ -373,7 +328,7 @@ def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
|
|
|
373
328
|
|
|
374
329
|
candidates = candidates_for(key, composition)
|
|
375
330
|
if len(candidates) == 0:
|
|
376
|
-
if is_builtin_skill(key, composition
|
|
331
|
+
if is_builtin_skill(key, composition):
|
|
377
332
|
# Asked after the composition, never before: an installed skill of
|
|
378
333
|
# the same name is the real target, and this is only what remains.
|
|
379
334
|
# `key` is dropped exactly as it is for a built-in tool: the agent
|
|
@@ -52,21 +52,19 @@ CAPABILITY_NAMES = frozenset(
|
|
|
52
52
|
#: claim about intent.
|
|
53
53
|
NEVER_OBSERVED = frozenset({"credential_access", "sensitive_data_access"})
|
|
54
54
|
|
|
55
|
-
#: A tool whose identity *is* the capability
|
|
56
|
-
#:
|
|
57
|
-
#:
|
|
58
|
-
#:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"WebFetch": "network_egress",
|
|
69
|
-
"WebSearch": "network_egress",
|
|
55
|
+
#: A tool whose identity *is* the capability, by the class OpenAIDR gives it
|
|
56
|
+
#: for every kind. No interpretation: a web fetch reaches the network because
|
|
57
|
+
#: that is what the tool is. Covers 95.6% of calls on a measured corpus --
|
|
58
|
+
#: 23,835 of 24,934 -- which is why every other mechanism below is a refinement
|
|
59
|
+
#: rather than the mechanism.
|
|
60
|
+
_CLASS_CAPABILITY: dict[str, str] = {
|
|
61
|
+
"shell": "shell_exec",
|
|
62
|
+
"file_read": "file_read",
|
|
63
|
+
"file_search": "file_read",
|
|
64
|
+
"file_write": "file_write",
|
|
65
|
+
"file_edit": "file_write",
|
|
66
|
+
"web_fetch": "network_egress",
|
|
67
|
+
"web_search": "network_egress",
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
#: Head binaries that say more about a shell call than `shell_exec` alone.
|
|
@@ -987,15 +985,16 @@ def observe(call: ToolCallLike) -> tuple[Observation, ...]:
|
|
|
987
985
|
# claimed by the server, is the only capability this function may report
|
|
988
986
|
# for one.
|
|
989
987
|
if not call.mcp_server:
|
|
990
|
-
capability =
|
|
988
|
+
capability = _CLASS_CAPABILITY.get(call.tool_class or "")
|
|
991
989
|
if capability:
|
|
992
990
|
found[capability] = Observation(
|
|
993
991
|
capability=capability,
|
|
994
992
|
confidence="high",
|
|
995
993
|
evidence={"kind": "tool_name", "value": call.tool_name},
|
|
996
994
|
)
|
|
997
|
-
if call.
|
|
998
|
-
|
|
995
|
+
if call.tool_class == "shell":
|
|
996
|
+
command = call.canonical_arguments.get("command")
|
|
997
|
+
for binary, arguments in command_segments(command):
|
|
999
998
|
for name, cited in _segment_capabilities(binary, arguments):
|
|
1000
999
|
# `shell_exec` is already recorded at high confidence from the
|
|
1001
1000
|
# tool itself; a parsed binary must never downgrade it.
|
|
@@ -1016,7 +1015,7 @@ def observe(call: ToolCallLike) -> tuple[Observation, ...]:
|
|
|
1016
1015
|
# Registering the redirect first would relabel those writes as
|
|
1017
1016
|
# redirect-derived and narrow a match to a target that is not theirs.
|
|
1018
1017
|
if "file_write" not in found:
|
|
1019
|
-
for target in redirect_targets(
|
|
1018
|
+
for target in redirect_targets(command):
|
|
1020
1019
|
found["file_write"] = Observation(
|
|
1021
1020
|
capability="file_write",
|
|
1022
1021
|
confidence="medium",
|