flightdeck-sensor 0.5.1__tar.gz → 0.5.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flightdeck_sensor-0.5.3/CLAUDE.md +78 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/PKG-INFO +4 -2
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/__init__.py +48 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/config.py +4 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/agent_id.py +1 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/context.py +10 -13
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/errors.py +1 -5
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/schemas.py +3 -1
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/session.py +12 -1
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/types.py +106 -1
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/anthropic.py +17 -5
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/base.py +26 -22
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/crewai.py +1 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/langgraph.py +1 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/litellm.py +38 -11
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/openai.py +13 -8
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/provider.py +1 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/anthropic.py +2 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/litellm.py +4 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/openai.py +2 -3
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/client.py +3 -8
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/pyproject.toml +14 -1
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/conftest.py +5 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_id.py +14 -37
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_type_validation.py +7 -10
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_compat_crewai_mcp.py +6 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_cross_agent_messages.py +8 -5
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_custom_directives.py +1 -5
- flightdeck_sensor-0.5.3/tests/unit/test_egress_url_validation.py +235 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_errors.py +28 -9
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_framework_attribution.py +27 -10
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor.py +36 -20
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_crewai.py +9 -6
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_langgraph.py +4 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_llm_event_enrichment.py +94 -38
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_interceptor.py +27 -11
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_patch.py +24 -27
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy.py +18 -8
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_events.py +13 -31
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_prompt_capture.py +12 -4
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_provider_enum.py +4 -2
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_providers.py +17 -15
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_session.py +8 -1
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_session_lifecycle_enrichment.py +48 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_transport.py +18 -22
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/.gitignore +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/Makefile +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/README.md +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/crewai_mcp.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/exceptions.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/mcp_policy.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/policy.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/protocol.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/py.typed +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/retry.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/__init__.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_context.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_d150_tool_capture_destination.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_identity.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_originating_event_id.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_decision_summary.py +0 -0
- {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_emission_enriched.py +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Flightdeck Sensor -- Claude Code Rules
|
|
2
|
+
|
|
3
|
+
> Component-specific rules moved out of the root CLAUDE.md so they load only
|
|
4
|
+
> when working under sensor/. All root CLAUDE.md rules still apply; the
|
|
5
|
+
> numbering below keeps the original rule numbers.
|
|
6
|
+
|
|
7
|
+
## Sensor Rules
|
|
8
|
+
|
|
9
|
+
27. **The sensor must never add meaningful latency to the agent's hot path.**
|
|
10
|
+
All control plane communication is fire-and-forget or background.
|
|
11
|
+
Never introduce synchronous blocking calls in the LLM call intercept path.
|
|
12
|
+
|
|
13
|
+
28. **The sensor must fail open.** If the control plane is unreachable and
|
|
14
|
+
FLIGHTDECK_UNAVAILABLE_POLICY=continue, the agent proceeds with no enforcement.
|
|
15
|
+
Do not raise exceptions for connectivity failures.
|
|
16
|
+
|
|
17
|
+
29. **Token counting carries over from tokencap.** Do not rewrite the counting logic.
|
|
18
|
+
Extend it. Pre-call estimation, post-call reconciliation, delta correction -- these
|
|
19
|
+
are proven and must not be changed without Supervisor approval.
|
|
20
|
+
|
|
21
|
+
30. **capture_prompts defaults to False.** The default init() call never captures
|
|
22
|
+
content. Always verify this default has not been accidentally changed.
|
|
23
|
+
|
|
24
|
+
31. **init() limit param fires WARN only.** Never upgrade a local limit to BLOCK
|
|
25
|
+
or DEGRADE regardless of what the server policy says. See DECISIONS.md D035.
|
|
26
|
+
|
|
27
|
+
32. **The sensor is a library wrapper, not an OS agent.** Never add background
|
|
28
|
+
threads, polling loops, or daemon threads to the sensor beyond the existing
|
|
29
|
+
event queue drain thread. If a feature requires background activity
|
|
30
|
+
independent of LLM calls, it does not belong in the sensor.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Framework Coverage Discipline
|
|
35
|
+
|
|
36
|
+
40d. **Framework coverage discipline.** Any phase that adds
|
|
37
|
+
framework support OR changes framework-emission behaviour
|
|
38
|
+
MUST include BOTH:
|
|
39
|
+
|
|
40
|
+
1. **Real-provider playground demos** per affected framework --
|
|
41
|
+
manual, NOT in CI (they cost money and need live API
|
|
42
|
+
credentials). Live under ``playground/`` and self-skip
|
|
43
|
+
(exit 2) when the relevant API key / framework / optional
|
|
44
|
+
gateway URL is missing so ``make playground-all`` runs
|
|
45
|
+
cleanly on any box. Driven via ``make playground-<script>``
|
|
46
|
+
targets. Each demo asserts payload shape inline using
|
|
47
|
+
``print_result`` + ``raise AssertionError``; ``run_all.py``
|
|
48
|
+
exits 0 only when every script returned 0 (PASS) or 2
|
|
49
|
+
(SKIP). Results documented in the phase's audit doc before
|
|
50
|
+
PR merge.
|
|
51
|
+
2. **Integration tests** per framework × behaviour combo,
|
|
52
|
+
mock-free (or lightly mocked at the network boundary),
|
|
53
|
+
running in CI via the existing Integration job. Seed a
|
|
54
|
+
realistic event payload for each new framework + behaviour
|
|
55
|
+
combination and verify end-to-end landing.
|
|
56
|
+
|
|
57
|
+
V-pass for such a phase MUST enumerate the playground demos
|
|
58
|
+
and integration tests that will be added before
|
|
59
|
+
implementation starts. Skipping either is a phase-gate
|
|
60
|
+
failure.
|
|
61
|
+
|
|
62
|
+
Why: Phase 4 (agent communication coverage hardening) shipped
|
|
63
|
+
embeddings, streaming semantics, structured error events, and
|
|
64
|
+
session-lifecycle edge-case fixes. Mock-only coverage would
|
|
65
|
+
have let a future SDK upgrade silently break the classifier
|
|
66
|
+
(anthropic renames ``RateLimitError`` to ``QuotaError`` and
|
|
67
|
+
our classifier falls through to ``other``; no CI gate catches
|
|
68
|
+
it). The playground matrix is the only thing that exercises
|
|
69
|
+
the real class hierarchy every provider ships.
|
|
70
|
+
|
|
71
|
+
Applies to: every phase from Phase 4 onwards that touches
|
|
72
|
+
``sensor/flightdeck_sensor/interceptor/*``, adds a new
|
|
73
|
+
interceptor file, or changes the event-emission shape for an
|
|
74
|
+
existing framework.
|
|
75
|
+
|
|
76
|
+
Does NOT apply to: non-framework sensor work (transport,
|
|
77
|
+
policy, directives) — those are covered by the standard unit
|
|
78
|
+
+ integration suites.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: flightdeck-sensor
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: In-process agent observability sensor for Flightdeck
|
|
5
|
+
Project-URL: Repository, https://github.com/flightdeckhq/flightdeck
|
|
6
|
+
Author-email: Omri Arnon <omri.arnon@gmail.com>
|
|
5
7
|
License-Expression: Apache-2.0
|
|
6
8
|
Classifier: Development Status :: 3 - Alpha
|
|
7
9
|
Classifier: Intended Audience :: Developers
|
|
@@ -118,6 +118,8 @@ def _resolve_user_name() -> str:
|
|
|
118
118
|
|
|
119
119
|
return pwd.getpwuid(os.getuid()).pw_name
|
|
120
120
|
except Exception:
|
|
121
|
+
# pwd missing or UID unmapped (distroless, scratch, Windows):
|
|
122
|
+
# fall through to the env-var probes below.
|
|
121
123
|
pass
|
|
122
124
|
for key in ("USER", "USERNAME", "LOGNAME"):
|
|
123
125
|
v = os.environ.get(key)
|
|
@@ -233,6 +235,7 @@ def init(
|
|
|
233
235
|
agent_name: str | None = None,
|
|
234
236
|
langgraph_agent_node_pattern: str | None = None,
|
|
235
237
|
mcp_block_on_uncertainty: bool = False,
|
|
238
|
+
allow_insecure_transport: bool = False,
|
|
236
239
|
) -> None:
|
|
237
240
|
"""Initialize the sensor and start the run.
|
|
238
241
|
|
|
@@ -296,6 +299,16 @@ def init(
|
|
|
296
299
|
for k8s pod grouping)
|
|
297
300
|
- ``FLIGHTDECK_UNAVAILABLE_POLICY`` -- ``"continue"`` or ``"halt"``
|
|
298
301
|
- ``FLIGHTDECK_CAPTURE_PROMPTS`` -- ``"true"`` to enable
|
|
302
|
+
- ``FLIGHTDECK_ALLOW_INSECURE_TRANSPORT`` -- ``"true"`` to allow plain
|
|
303
|
+
``http://`` egress to a non-local control plane (overrides
|
|
304
|
+
*allow_insecure_transport*)
|
|
305
|
+
|
|
306
|
+
``allow_insecure_transport`` opts in to plaintext ``http://`` egress
|
|
307
|
+
for a **non-local** control-plane host. Off by default: the resolved
|
|
308
|
+
``server`` / ``api_url`` must use ``https://`` for any public host, or
|
|
309
|
+
init raises. Local / loopback / private hosts (``localhost``,
|
|
310
|
+
``127.0.0.1``, RFC1918, ...) are always allowed over ``http``. A URL
|
|
311
|
+
with any scheme other than ``http`` / ``https`` is rejected outright.
|
|
299
312
|
|
|
300
313
|
``langgraph_agent_node_pattern`` (D126) is an optional regex
|
|
301
314
|
string that narrows which LangGraph nodes the sub-agent
|
|
@@ -314,6 +327,23 @@ def init(
|
|
|
314
327
|
_log.warning("flightdeck_sensor.init() called twice; ignoring")
|
|
315
328
|
return
|
|
316
329
|
|
|
330
|
+
# H-01: env-var precedence is preserved (env wins over the kwarg,
|
|
331
|
+
# matching every other init() knob), but a silent override of an
|
|
332
|
+
# explicitly-passed value is the vulnerability — a stale/hostile
|
|
333
|
+
# FLIGHTDECK_SERVER in the environment could redirect egress with
|
|
334
|
+
# no signal. Warn (naming both values) when the environment
|
|
335
|
+
# overrides a non-empty caller-supplied server with a different
|
|
336
|
+
# value, then keep the env value.
|
|
337
|
+
env_server = os.environ.get(_env.ENV_SERVER)
|
|
338
|
+
if env_server is not None and server and env_server != server:
|
|
339
|
+
_log.warning(
|
|
340
|
+
"%s=%r overrides the server=%r passed to init(); using the "
|
|
341
|
+
"environment value. Unset %s if this was not intended.",
|
|
342
|
+
_env.ENV_SERVER,
|
|
343
|
+
env_server,
|
|
344
|
+
server,
|
|
345
|
+
_env.ENV_SERVER,
|
|
346
|
+
)
|
|
317
347
|
resolved_server = os.environ.get(_env.ENV_SERVER, server)
|
|
318
348
|
resolved_token = os.environ.get(_env.ENV_TOKEN, token)
|
|
319
349
|
if not resolved_server:
|
|
@@ -321,6 +351,12 @@ def init(
|
|
|
321
351
|
if not resolved_token:
|
|
322
352
|
raise ConfigurationError("token is required")
|
|
323
353
|
|
|
354
|
+
# H-01: resolve the plaintext-transport opt-in. Env wins over the
|
|
355
|
+
# kwarg (same precedence as every other flag); default False.
|
|
356
|
+
resolved_allow_insecure = _env_bool(
|
|
357
|
+
_env.ENV_ALLOW_INSECURE_TRANSPORT, allow_insecure_transport
|
|
358
|
+
)
|
|
359
|
+
|
|
324
360
|
# KI20: normalize ``FLIGHTDECK_SERVER`` / ``server`` kwarg to
|
|
325
361
|
# always carry the ``/ingest`` suffix. The Claude Code plugin
|
|
326
362
|
# uses the same env var without the suffix (it appends
|
|
@@ -332,6 +368,17 @@ def init(
|
|
|
332
368
|
if "/ingest" not in resolved_server:
|
|
333
369
|
resolved_server = resolved_server.rstrip("/") + "/ingest"
|
|
334
370
|
|
|
371
|
+
# H-01: same silent-override guard for the control-plane API URL.
|
|
372
|
+
env_api_url = os.environ.get(_env.ENV_API_URL)
|
|
373
|
+
if env_api_url and api_url and env_api_url != api_url:
|
|
374
|
+
_log.warning(
|
|
375
|
+
"%s=%r overrides the api_url=%r passed to init(); using the "
|
|
376
|
+
"environment value. Unset %s if this was not intended.",
|
|
377
|
+
_env.ENV_API_URL,
|
|
378
|
+
env_api_url,
|
|
379
|
+
api_url,
|
|
380
|
+
_env.ENV_API_URL,
|
|
381
|
+
)
|
|
335
382
|
resolved_api_url = os.environ.get(_env.ENV_API_URL) or api_url
|
|
336
383
|
if not resolved_api_url:
|
|
337
384
|
resolved_api_url = resolved_server.rstrip("/").replace("/ingest", "/api")
|
|
@@ -417,6 +464,7 @@ def init(
|
|
|
417
464
|
"token": resolved_token,
|
|
418
465
|
"api_url": resolved_api_url,
|
|
419
466
|
"capture_prompts": capture,
|
|
467
|
+
"allow_insecure_transport": resolved_allow_insecure,
|
|
420
468
|
"unavailable_policy": os.environ.get(_env.ENV_UNAVAILABLE_POLICY, "continue"),
|
|
421
469
|
# Legacy wire-level ``flavor`` field stays populated for
|
|
422
470
|
# backward compat with every downstream surface that still
|
|
@@ -41,6 +41,9 @@ ENV_AGENT_FLAVOR_LEGACY = "AGENT_FLAVOR"
|
|
|
41
41
|
# ----------------------------------------------------------------
|
|
42
42
|
ENV_CAPTURE_PROMPTS = "FLIGHTDECK_CAPTURE_PROMPTS"
|
|
43
43
|
ENV_UNAVAILABLE_POLICY = "FLIGHTDECK_UNAVAILABLE_POLICY"
|
|
44
|
+
# H-01: opt-in to plaintext http:// egress for a non-local control
|
|
45
|
+
# plane. Bool-coerced; default False forces https:// for public hosts.
|
|
46
|
+
ENV_ALLOW_INSECURE_TRANSPORT = "FLIGHTDECK_ALLOW_INSECURE_TRANSPORT"
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
# Stable list of every FLIGHTDECK_* env var the sensor consults, in
|
|
@@ -56,4 +59,5 @@ ALL_ENV_VARS: tuple[str, ...] = (
|
|
|
56
59
|
ENV_HOSTNAME,
|
|
57
60
|
ENV_CAPTURE_PROMPTS,
|
|
58
61
|
ENV_UNAVAILABLE_POLICY,
|
|
62
|
+
ENV_ALLOW_INSECURE_TRANSPORT,
|
|
59
63
|
)
|
|
@@ -74,10 +74,7 @@ def derive_agent_id(
|
|
|
74
74
|
Returns a :class:`uuid.UUID`. Callers that need the canonical
|
|
75
75
|
string form use ``str(derive_agent_id(...))``.
|
|
76
76
|
"""
|
|
77
|
-
path =
|
|
78
|
-
f"flightdeck://{agent_type}/{user}@{hostname}"
|
|
79
|
-
f"/{client_type}/{agent_name}"
|
|
80
|
-
)
|
|
77
|
+
path = f"flightdeck://{agent_type}/{user}@{hostname}/{client_type}/{agent_name}"
|
|
81
78
|
if agent_role is not None:
|
|
82
79
|
trimmed = agent_role.strip()
|
|
83
80
|
if trimmed:
|
|
@@ -33,6 +33,7 @@ import re
|
|
|
33
33
|
import socket
|
|
34
34
|
import subprocess
|
|
35
35
|
import sys
|
|
36
|
+
from pathlib import Path
|
|
36
37
|
from typing import Any, Protocol
|
|
37
38
|
|
|
38
39
|
# Subprocess timeout for ``git`` invocations in GitCollector (M-5).
|
|
@@ -80,7 +81,7 @@ class ProcessCollector(BaseCollector):
|
|
|
80
81
|
result["pid"] = os.getpid()
|
|
81
82
|
argv0 = sys.argv[0] if sys.argv else ""
|
|
82
83
|
if argv0:
|
|
83
|
-
result["process_name"] =
|
|
84
|
+
result["process_name"] = Path(argv0).name
|
|
84
85
|
return result
|
|
85
86
|
|
|
86
87
|
|
|
@@ -96,11 +97,7 @@ class OSCollector(BaseCollector):
|
|
|
96
97
|
class UserCollector(BaseCollector):
|
|
97
98
|
def _gather(self) -> dict[str, Any]:
|
|
98
99
|
# USER on Unix, USERNAME on Windows, LOGNAME as final fallback.
|
|
99
|
-
user = (
|
|
100
|
-
os.environ.get("USER")
|
|
101
|
-
or os.environ.get("USERNAME")
|
|
102
|
-
or os.environ.get("LOGNAME")
|
|
103
|
-
)
|
|
100
|
+
user = os.environ.get("USER") or os.environ.get("USERNAME") or os.environ.get("LOGNAME")
|
|
104
101
|
return {"user": user} if user else {}
|
|
105
102
|
|
|
106
103
|
|
|
@@ -149,7 +146,7 @@ class GitCollector(BaseCollector):
|
|
|
149
146
|
if remote:
|
|
150
147
|
# Strip embedded credentials before extracting the repo name.
|
|
151
148
|
clean = re.sub(r"https?://[^@]+@", "https://", remote)
|
|
152
|
-
repo =
|
|
149
|
+
repo = Path(clean.rstrip("/")).name
|
|
153
150
|
if repo.endswith(".git"):
|
|
154
151
|
repo = repo[:-4]
|
|
155
152
|
if repo:
|
|
@@ -186,10 +183,7 @@ class KubernetesCollector(BaseCollector):
|
|
|
186
183
|
|
|
187
184
|
class DockerComposeCollector(BaseCollector):
|
|
188
185
|
def applies(self) -> bool:
|
|
189
|
-
return bool(
|
|
190
|
-
os.environ.get("COMPOSE_PROJECT_NAME")
|
|
191
|
-
or os.environ.get("COMPOSE_SERVICE")
|
|
192
|
-
)
|
|
186
|
+
return bool(os.environ.get("COMPOSE_PROJECT_NAME") or os.environ.get("COMPOSE_SERVICE"))
|
|
193
187
|
|
|
194
188
|
def _gather(self) -> dict[str, Any]:
|
|
195
189
|
result: dict[str, Any] = {"orchestration": "docker-compose"}
|
|
@@ -207,7 +201,7 @@ class DockerCollector(BaseCollector):
|
|
|
207
201
|
# /.dockerenv only exists inside Linux containers; on Windows
|
|
208
202
|
# or Mac hosts running Python natively this collector
|
|
209
203
|
# correctly returns nothing.
|
|
210
|
-
return
|
|
204
|
+
return Path("/.dockerenv").exists()
|
|
211
205
|
|
|
212
206
|
def _gather(self) -> dict[str, Any]:
|
|
213
207
|
return {"orchestration": "docker"}
|
|
@@ -224,7 +218,7 @@ class AWSECSCollector(BaseCollector):
|
|
|
224
218
|
result: dict[str, Any] = {"orchestration": "aws-ecs"}
|
|
225
219
|
task = os.environ.get("ECS_TASK_DEFINITION")
|
|
226
220
|
if task:
|
|
227
|
-
result["ecs_task"] =
|
|
221
|
+
result["ecs_task"] = Path(task).name
|
|
228
222
|
return result
|
|
229
223
|
|
|
230
224
|
|
|
@@ -397,6 +391,7 @@ def collect() -> dict[str, Any]:
|
|
|
397
391
|
if collector.applies():
|
|
398
392
|
ctx.update(collector.collect())
|
|
399
393
|
except Exception:
|
|
394
|
+
# best-effort: a single broken collector must never crash init().
|
|
400
395
|
pass
|
|
401
396
|
|
|
402
397
|
for collector in ORCHESTRATION_COLLECTORS:
|
|
@@ -405,6 +400,7 @@ def collect() -> dict[str, Any]:
|
|
|
405
400
|
ctx.update(collector.collect())
|
|
406
401
|
break
|
|
407
402
|
except Exception:
|
|
403
|
+
# best-effort: a single broken collector must never crash init().
|
|
408
404
|
pass
|
|
409
405
|
|
|
410
406
|
for collector in OTHER_COLLECTORS:
|
|
@@ -412,6 +408,7 @@ def collect() -> dict[str, Any]:
|
|
|
412
408
|
if collector.applies():
|
|
413
409
|
ctx.update(collector.collect())
|
|
414
410
|
except Exception:
|
|
411
|
+
# best-effort: a single broken collector must never crash init().
|
|
415
412
|
pass
|
|
416
413
|
|
|
417
414
|
return ctx
|
|
@@ -166,11 +166,7 @@ def _override_from_status_and_code(
|
|
|
166
166
|
# OpenAI sometimes returns 503 "engine overloaded"; distinguish on
|
|
167
167
|
# the message hint when present. The combined condition keeps ruff's
|
|
168
168
|
# SIM102 happy without losing the 503-vs-500 branch clarity.
|
|
169
|
-
if (
|
|
170
|
-
http_status == 503
|
|
171
|
-
and provider_error_code
|
|
172
|
-
and "overload" in provider_error_code.lower()
|
|
173
|
-
):
|
|
169
|
+
if http_status == 503 and provider_error_code and "overload" in provider_error_code.lower():
|
|
174
170
|
return ErrorType.OVERLOADED
|
|
175
171
|
if http_status == 400 and provider_error_code:
|
|
176
172
|
code = provider_error_code.lower()
|
|
@@ -11,6 +11,8 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
|
|
14
|
+
from flightdeck_sensor.core.types import _DEFAULT_GRACE_PERIOD_MS
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
class DirectivePayloadSchema(BaseModel):
|
|
16
18
|
"""Payload for action='custom' directives."""
|
|
@@ -48,7 +50,7 @@ class DirectiveResponseSchema(BaseModel):
|
|
|
48
50
|
|
|
49
51
|
action: str
|
|
50
52
|
reason: str | None = None
|
|
51
|
-
grace_period_ms: int =
|
|
53
|
+
grace_period_ms: int = _DEFAULT_GRACE_PERIOD_MS
|
|
52
54
|
degrade_to: str | None = None
|
|
53
55
|
payload: dict[str, Any] | None = None
|
|
54
56
|
|
|
@@ -148,6 +148,12 @@ _PREFLIGHT_TIMEOUT_SECS = 1
|
|
|
148
148
|
# directive queue but NOT event throughput.
|
|
149
149
|
_CUSTOM_DIRECTIVE_HANDLER_TIMEOUT_SECS = 5
|
|
150
150
|
|
|
151
|
+
# Per-(provider, request_id) retry-counter LRU bound. Eviction-on-overflow
|
|
152
|
+
# keeps memory flat for long-lived sessions making millions of calls; the
|
|
153
|
+
# counter only answers a short-window question ("did this retry chain give
|
|
154
|
+
# up"), so dropping the oldest entries is safe.
|
|
155
|
+
_RETRY_COUNTER_LRU_SIZE = 256
|
|
156
|
+
|
|
151
157
|
# D126 § 6 sub-agent message routing thresholds.
|
|
152
158
|
#
|
|
153
159
|
# Bodies up to ``SUBAGENT_INLINE_THRESHOLD_BYTES`` ride inline on the
|
|
@@ -404,7 +410,7 @@ class Session:
|
|
|
404
410
|
current = self._retry_counters.get(key, 0) + 1
|
|
405
411
|
self._retry_counters[key] = current
|
|
406
412
|
self._retry_counters.move_to_end(key)
|
|
407
|
-
while len(self._retry_counters) >
|
|
413
|
+
while len(self._retry_counters) > _RETRY_COUNTER_LRU_SIZE:
|
|
408
414
|
self._retry_counters.popitem(last=False)
|
|
409
415
|
return current
|
|
410
416
|
|
|
@@ -1267,6 +1273,11 @@ class Session:
|
|
|
1267
1273
|
# "what budget/MCP rules were in effect at session start"
|
|
1268
1274
|
# without joining time-windowed policy state.
|
|
1269
1275
|
payload["sensor_version"] = _sensor_version()
|
|
1276
|
+
# C-01: emit the session's capture posture authoritatively at
|
|
1277
|
+
# session_start so the server can persist whether this session
|
|
1278
|
+
# is capturing prompt content. Sourced directly from the
|
|
1279
|
+
# resolved config — the single source of truth for the flag.
|
|
1280
|
+
payload["capture_prompts"] = self.config.capture_prompts
|
|
1270
1281
|
iv = _collect_interceptor_versions()
|
|
1271
1282
|
if iv:
|
|
1272
1283
|
payload["interceptor_versions"] = iv
|
|
@@ -3,9 +3,87 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import enum
|
|
6
|
+
import ipaddress
|
|
6
7
|
import uuid
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from typing import Any, Callable, Literal
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
# Schemes the sensor is willing to egress control-plane traffic over.
|
|
13
|
+
# Anything else (file://, ftp://, gopher://, a bare host with no
|
|
14
|
+
# scheme, ...) is rejected outright — the sensor only speaks HTTP(S)
|
|
15
|
+
# to the control plane, and an unexpected scheme is almost always a
|
|
16
|
+
# misconfiguration or an attempt to redirect egress somewhere it does
|
|
17
|
+
# not belong.
|
|
18
|
+
_ALLOWED_EGRESS_SCHEMES = frozenset({"https", "http"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_local_host(host: str) -> bool:
|
|
22
|
+
"""Return True when *host* is a local/loopback/private target for
|
|
23
|
+
which plain ``http://`` is acceptable without an explicit opt-in.
|
|
24
|
+
|
|
25
|
+
Covers ``localhost`` (and ``*.localhost``), loopback IPs
|
|
26
|
+
(127.0.0.0/8, ::1), RFC1918 / ULA private ranges, and link-local
|
|
27
|
+
addresses. Everything else — public hostnames and public IPs —
|
|
28
|
+
is treated as remote and must use TLS unless the operator opts in.
|
|
29
|
+
"""
|
|
30
|
+
if not host:
|
|
31
|
+
return False
|
|
32
|
+
h = host.lower()
|
|
33
|
+
if h == "localhost" or h.endswith(".localhost"):
|
|
34
|
+
return True
|
|
35
|
+
try:
|
|
36
|
+
ip = ipaddress.ip_address(h)
|
|
37
|
+
except ValueError:
|
|
38
|
+
# Not an IP literal and not a localhost name → treat as a
|
|
39
|
+
# public host. DNS is deliberately NOT resolved here: the
|
|
40
|
+
# sensor must never make a blocking network call on the config
|
|
41
|
+
# path (Sensor Rule 27), and resolving would also open a
|
|
42
|
+
# rebinding hole. Operators pointing at a private hostname that
|
|
43
|
+
# only resolves to an RFC1918 address use the opt-in flag.
|
|
44
|
+
return False
|
|
45
|
+
return ip.is_loopback or ip.is_private or ip.is_link_local
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_egress_url(
|
|
49
|
+
url: str,
|
|
50
|
+
*,
|
|
51
|
+
field_name: str,
|
|
52
|
+
allow_insecure_transport: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Validate a resolved control-plane egress URL (H-01).
|
|
55
|
+
|
|
56
|
+
Raises :class:`ValueError` when:
|
|
57
|
+
|
|
58
|
+
- the URL does not parse to a scheme in ``{"https", "http"}`` — this
|
|
59
|
+
rejects ``file://``, ``ftp://``, scheme-less strings, etc.; or
|
|
60
|
+
- the URL uses plain ``http://`` for a non-local host and the
|
|
61
|
+
operator has not opted into insecure transport
|
|
62
|
+
(``allow_insecure_transport`` / ``FLIGHTDECK_ALLOW_INSECURE_TRANSPORT``).
|
|
63
|
+
|
|
64
|
+
``field_name`` is the config field being checked (``"server"`` /
|
|
65
|
+
``"api_url"``) and is woven into the error message so a
|
|
66
|
+
misconfiguration is self-describing.
|
|
67
|
+
"""
|
|
68
|
+
parsed = urlparse(url)
|
|
69
|
+
scheme = parsed.scheme.lower()
|
|
70
|
+
if scheme not in _ALLOWED_EGRESS_SCHEMES:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"{field_name} must use an http:// or https:// URL; got "
|
|
73
|
+
f"scheme {scheme or '(none)'!r} in {url!r}. The sensor only "
|
|
74
|
+
"egresses control-plane traffic over HTTP(S)."
|
|
75
|
+
)
|
|
76
|
+
if scheme == "http" and not allow_insecure_transport:
|
|
77
|
+
host = parsed.hostname or ""
|
|
78
|
+
if not _is_local_host(host):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"{field_name} uses insecure http:// for non-local host "
|
|
81
|
+
f"{host or '(none)'!r} ({url!r}). Use https://, or opt in "
|
|
82
|
+
"to plaintext transport by setting allow_insecure_transport"
|
|
83
|
+
"=True (env FLIGHTDECK_ALLOW_INSECURE_TRANSPORT=true). "
|
|
84
|
+
"Plaintext control-plane traffic to a remote host exposes "
|
|
85
|
+
"the access token and event payloads on the wire."
|
|
86
|
+
)
|
|
9
87
|
|
|
10
88
|
|
|
11
89
|
class SessionState(enum.Enum):
|
|
@@ -215,6 +293,13 @@ class SensorConfig:
|
|
|
215
293
|
client_type: str = "flightdeck_sensor"
|
|
216
294
|
api_url: str = ""
|
|
217
295
|
capture_prompts: bool = False
|
|
296
|
+
# H-01: opt-in to plaintext http:// egress for a non-local control
|
|
297
|
+
# plane. Default False forces https:// for any public host; local /
|
|
298
|
+
# loopback / private targets are always allowed over http regardless.
|
|
299
|
+
# Env-readable via FLIGHTDECK_ALLOW_INSECURE_TRANSPORT (resolved in
|
|
300
|
+
# init()). Never flip this on for a real deployment — it exposes the
|
|
301
|
+
# access token and event payloads on the wire.
|
|
302
|
+
allow_insecure_transport: bool = False
|
|
218
303
|
unavailable_policy: str = "continue"
|
|
219
304
|
agent_flavor: str = "unknown"
|
|
220
305
|
agent_type: str = "production"
|
|
@@ -233,6 +318,26 @@ class SensorConfig:
|
|
|
233
318
|
def __post_init__(self) -> None:
|
|
234
319
|
if not self.api_url:
|
|
235
320
|
self.api_url = self.server
|
|
321
|
+
# H-01: validate the resolved control-plane egress URLs. Both
|
|
322
|
+
# ``server`` (ingestion) and ``api_url`` (control-plane API) are
|
|
323
|
+
# checked so every egress target is covered by the single helper.
|
|
324
|
+
validate_egress_url(
|
|
325
|
+
self.server,
|
|
326
|
+
field_name="server",
|
|
327
|
+
allow_insecure_transport=self.allow_insecure_transport,
|
|
328
|
+
)
|
|
329
|
+
validate_egress_url(
|
|
330
|
+
self.api_url,
|
|
331
|
+
field_name="api_url",
|
|
332
|
+
allow_insecure_transport=self.allow_insecure_transport,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# Default directive grace period (ms): the window an agent gets to wind down
|
|
337
|
+
# the current call before an enforcement action (degrade / block / shutdown)
|
|
338
|
+
# takes effect. Mirrored as the default in DirectiveResponseSchema so a
|
|
339
|
+
# wire-absent value resolves identically.
|
|
340
|
+
_DEFAULT_GRACE_PERIOD_MS = 5000
|
|
236
341
|
|
|
237
342
|
|
|
238
343
|
@dataclass(frozen=True)
|
|
@@ -241,7 +346,7 @@ class Directive:
|
|
|
241
346
|
|
|
242
347
|
action: DirectiveAction
|
|
243
348
|
reason: str
|
|
244
|
-
grace_period_ms: int =
|
|
349
|
+
grace_period_ms: int = _DEFAULT_GRACE_PERIOD_MS
|
|
245
350
|
payload: dict[str, Any] = field(default_factory=dict)
|
|
246
351
|
|
|
247
352
|
|
{flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/anthropic.py
RENAMED
|
@@ -94,6 +94,7 @@ try:
|
|
|
94
94
|
from anthropic.resources.beta.beta import (
|
|
95
95
|
Beta as _OrigBetaImported,
|
|
96
96
|
)
|
|
97
|
+
|
|
97
98
|
_OrigAnthropic: type | None = _anthropic_module.Anthropic
|
|
98
99
|
_OrigAsyncAnthropic: type | None = _anthropic_module.AsyncAnthropic
|
|
99
100
|
# The Beta / AsyncBeta classes are imported at module load so the
|
|
@@ -162,7 +163,10 @@ class SensorMessages:
|
|
|
162
163
|
"""
|
|
163
164
|
if self._is_async:
|
|
164
165
|
return base.call_stream_async(
|
|
165
|
-
self._real.stream,
|
|
166
|
+
self._real.stream,
|
|
167
|
+
kwargs,
|
|
168
|
+
self._session,
|
|
169
|
+
self._provider,
|
|
166
170
|
)
|
|
167
171
|
return base.call_stream(self._real.stream, kwargs, self._session, self._provider)
|
|
168
172
|
|
|
@@ -425,19 +429,27 @@ def patch_anthropic_classes(quiet: bool = False) -> None:
|
|
|
425
429
|
return
|
|
426
430
|
|
|
427
431
|
_patch_one_class(
|
|
428
|
-
_OrigAnthropic,
|
|
432
|
+
_OrigAnthropic,
|
|
433
|
+
is_async=False,
|
|
434
|
+
quiet=quiet,
|
|
429
435
|
descriptor_cls=_AnthropicMessagesDescriptor,
|
|
430
436
|
)
|
|
431
437
|
_patch_one_class(
|
|
432
|
-
_OrigAsyncAnthropic,
|
|
438
|
+
_OrigAsyncAnthropic,
|
|
439
|
+
is_async=True,
|
|
440
|
+
quiet=quiet,
|
|
433
441
|
descriptor_cls=_AnthropicMessagesDescriptor,
|
|
434
442
|
)
|
|
435
443
|
_patch_one_class(
|
|
436
|
-
_OrigBeta,
|
|
444
|
+
_OrigBeta,
|
|
445
|
+
is_async=False,
|
|
446
|
+
quiet=quiet,
|
|
437
447
|
descriptor_cls=_AnthropicBetaMessagesDescriptor,
|
|
438
448
|
)
|
|
439
449
|
_patch_one_class(
|
|
440
|
-
_OrigAsyncBeta,
|
|
450
|
+
_OrigAsyncBeta,
|
|
451
|
+
is_async=True,
|
|
452
|
+
quiet=quiet,
|
|
441
453
|
descriptor_cls=_AnthropicBetaMessagesDescriptor,
|
|
442
454
|
)
|
|
443
455
|
|