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.
Files changed (73) hide show
  1. flightdeck_sensor-0.5.3/CLAUDE.md +78 -0
  2. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/PKG-INFO +4 -2
  3. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/__init__.py +48 -0
  4. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/config.py +4 -0
  5. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/agent_id.py +1 -4
  6. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/context.py +10 -13
  7. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/errors.py +1 -5
  8. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/schemas.py +3 -1
  9. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/session.py +12 -1
  10. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/types.py +106 -1
  11. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/anthropic.py +17 -5
  12. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/base.py +26 -22
  13. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/crewai.py +1 -0
  14. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/langgraph.py +1 -0
  15. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/litellm.py +38 -11
  16. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/openai.py +13 -8
  17. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/provider.py +1 -0
  18. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/anthropic.py +2 -0
  19. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/litellm.py +4 -4
  20. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/openai.py +2 -3
  21. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/client.py +3 -8
  22. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/pyproject.toml +14 -1
  23. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/conftest.py +5 -4
  24. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_id.py +14 -37
  25. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_type_validation.py +7 -10
  26. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_compat_crewai_mcp.py +6 -4
  27. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_cross_agent_messages.py +8 -5
  28. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_custom_directives.py +1 -5
  29. flightdeck_sensor-0.5.3/tests/unit/test_egress_url_validation.py +235 -0
  30. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_errors.py +28 -9
  31. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_framework_attribution.py +27 -10
  32. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor.py +36 -20
  33. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_crewai.py +9 -6
  34. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_langgraph.py +4 -4
  35. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_llm_event_enrichment.py +94 -38
  36. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_interceptor.py +27 -11
  37. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_patch.py +24 -27
  38. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy.py +18 -8
  39. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_events.py +13 -31
  40. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_prompt_capture.py +12 -4
  41. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_provider_enum.py +4 -2
  42. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_providers.py +17 -15
  43. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_session.py +8 -1
  44. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_session_lifecycle_enrichment.py +48 -0
  45. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_transport.py +18 -22
  46. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/.gitignore +0 -0
  47. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/Makefile +0 -0
  48. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/README.md +0 -0
  49. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/__init__.py +0 -0
  50. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/crewai_mcp.py +0 -0
  51. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/__init__.py +0 -0
  52. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/exceptions.py +0 -0
  53. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/mcp_policy.py +0 -0
  54. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/policy.py +0 -0
  55. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/__init__.py +0 -0
  56. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp.py +0 -0
  57. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
  58. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/__init__.py +0 -0
  59. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/protocol.py +0 -0
  60. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/py.typed +0 -0
  61. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/__init__.py +0 -0
  62. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/retry.py +0 -0
  63. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/__init__.py +0 -0
  64. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/__init__.py +0 -0
  65. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_context.py +0 -0
  66. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_d150_tool_capture_destination.py +0 -0
  67. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_identity.py +0 -0
  68. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
  69. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement.py +0 -0
  70. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
  71. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_originating_event_id.py +0 -0
  72. {flightdeck_sensor-0.5.1 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_decision_summary.py +0 -0
  73. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: flightdeck-sensor
3
- Version: 0.5.1
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"] = os.path.basename(argv0)
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 = os.path.basename(clean.rstrip("/"))
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 os.path.exists("/.dockerenv")
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"] = os.path.basename(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 = 5000
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) > 256:
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 = 5000
349
+ grace_period_ms: int = _DEFAULT_GRACE_PERIOD_MS
245
350
  payload: dict[str, Any] = field(default_factory=dict)
246
351
 
247
352
 
@@ -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, kwargs, self._session, self._provider,
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, is_async=False, quiet=quiet,
432
+ _OrigAnthropic,
433
+ is_async=False,
434
+ quiet=quiet,
429
435
  descriptor_cls=_AnthropicMessagesDescriptor,
430
436
  )
431
437
  _patch_one_class(
432
- _OrigAsyncAnthropic, is_async=True, quiet=quiet,
438
+ _OrigAsyncAnthropic,
439
+ is_async=True,
440
+ quiet=quiet,
433
441
  descriptor_cls=_AnthropicMessagesDescriptor,
434
442
  )
435
443
  _patch_one_class(
436
- _OrigBeta, is_async=False, quiet=quiet,
444
+ _OrigBeta,
445
+ is_async=False,
446
+ quiet=quiet,
437
447
  descriptor_cls=_AnthropicBetaMessagesDescriptor,
438
448
  )
439
449
  _patch_one_class(
440
- _OrigAsyncBeta, is_async=True, quiet=quiet,
450
+ _OrigAsyncBeta,
451
+ is_async=True,
452
+ quiet=quiet,
441
453
  descriptor_cls=_AnthropicBetaMessagesDescriptor,
442
454
  )
443
455