flightdeck-sensor 0.5.2__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.2 → flightdeck_sensor-0.5.3}/PKG-INFO +4 -2
  3. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/__init__.py +46 -0
  4. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/config.py +4 -0
  5. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/session.py +5 -0
  6. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/types.py +98 -0
  7. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/pyproject.toml +7 -1
  8. flightdeck_sensor-0.5.3/tests/unit/test_egress_url_validation.py +235 -0
  9. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_session.py +8 -1
  10. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_session_lifecycle_enrichment.py +48 -0
  11. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/.gitignore +0 -0
  12. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/Makefile +0 -0
  13. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/README.md +0 -0
  14. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/__init__.py +0 -0
  15. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/compat/crewai_mcp.py +0 -0
  16. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/__init__.py +0 -0
  17. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/agent_id.py +0 -0
  18. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/context.py +0 -0
  19. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/errors.py +0 -0
  20. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/exceptions.py +0 -0
  21. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/mcp_policy.py +0 -0
  22. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/policy.py +0 -0
  23. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/core/schemas.py +0 -0
  24. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/__init__.py +0 -0
  25. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/anthropic.py +0 -0
  26. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/base.py +0 -0
  27. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/crewai.py +0 -0
  28. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/langgraph.py +0 -0
  29. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/litellm.py +0 -0
  30. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp.py +0 -0
  31. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
  32. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/interceptor/openai.py +0 -0
  33. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/provider.py +0 -0
  34. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/__init__.py +0 -0
  35. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/anthropic.py +0 -0
  36. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/litellm.py +0 -0
  37. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/openai.py +0 -0
  38. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/providers/protocol.py +0 -0
  39. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/py.typed +0 -0
  40. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/__init__.py +0 -0
  41. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/client.py +0 -0
  42. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/flightdeck_sensor/transport/retry.py +0 -0
  43. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/__init__.py +0 -0
  44. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/conftest.py +0 -0
  45. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/__init__.py +0 -0
  46. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_id.py +0 -0
  47. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_agent_type_validation.py +0 -0
  48. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_compat_crewai_mcp.py +0 -0
  49. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_context.py +0 -0
  50. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_cross_agent_messages.py +0 -0
  51. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_custom_directives.py +0 -0
  52. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_d150_tool_capture_destination.py +0 -0
  53. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_errors.py +0 -0
  54. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_framework_attribution.py +0 -0
  55. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor.py +0 -0
  56. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_crewai.py +0 -0
  57. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_interceptor_langgraph.py +0 -0
  58. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_llm_event_enrichment.py +0 -0
  59. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_identity.py +0 -0
  60. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_interceptor.py +0 -0
  61. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
  62. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement.py +0 -0
  63. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
  64. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_originating_event_id.py +0 -0
  65. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_patch.py +0 -0
  66. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_policy.py +0 -0
  67. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_decision_summary.py +0 -0
  68. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_emission_enriched.py +0 -0
  69. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_policy_events.py +0 -0
  70. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_prompt_capture.py +0 -0
  71. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_provider_enum.py +0 -0
  72. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_providers.py +0 -0
  73. {flightdeck_sensor-0.5.2 → flightdeck_sensor-0.5.3}/tests/unit/test_transport.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.2
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
@@ -235,6 +235,7 @@ def init(
235
235
  agent_name: str | None = None,
236
236
  langgraph_agent_node_pattern: str | None = None,
237
237
  mcp_block_on_uncertainty: bool = False,
238
+ allow_insecure_transport: bool = False,
238
239
  ) -> None:
239
240
  """Initialize the sensor and start the run.
240
241
 
@@ -298,6 +299,16 @@ def init(
298
299
  for k8s pod grouping)
299
300
  - ``FLIGHTDECK_UNAVAILABLE_POLICY`` -- ``"continue"`` or ``"halt"``
300
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.
301
312
 
302
313
  ``langgraph_agent_node_pattern`` (D126) is an optional regex
303
314
  string that narrows which LangGraph nodes the sub-agent
@@ -316,6 +327,23 @@ def init(
316
327
  _log.warning("flightdeck_sensor.init() called twice; ignoring")
317
328
  return
318
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
+ )
319
347
  resolved_server = os.environ.get(_env.ENV_SERVER, server)
320
348
  resolved_token = os.environ.get(_env.ENV_TOKEN, token)
321
349
  if not resolved_server:
@@ -323,6 +351,12 @@ def init(
323
351
  if not resolved_token:
324
352
  raise ConfigurationError("token is required")
325
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
+
326
360
  # KI20: normalize ``FLIGHTDECK_SERVER`` / ``server`` kwarg to
327
361
  # always carry the ``/ingest`` suffix. The Claude Code plugin
328
362
  # uses the same env var without the suffix (it appends
@@ -334,6 +368,17 @@ def init(
334
368
  if "/ingest" not in resolved_server:
335
369
  resolved_server = resolved_server.rstrip("/") + "/ingest"
336
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
+ )
337
382
  resolved_api_url = os.environ.get(_env.ENV_API_URL) or api_url
338
383
  if not resolved_api_url:
339
384
  resolved_api_url = resolved_server.rstrip("/").replace("/ingest", "/api")
@@ -419,6 +464,7 @@ def init(
419
464
  "token": resolved_token,
420
465
  "api_url": resolved_api_url,
421
466
  "capture_prompts": capture,
467
+ "allow_insecure_transport": resolved_allow_insecure,
422
468
  "unavailable_policy": os.environ.get(_env.ENV_UNAVAILABLE_POLICY, "continue"),
423
469
  # Legacy wire-level ``flavor`` field stays populated for
424
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
  )
@@ -1273,6 +1273,11 @@ class Session:
1273
1273
  # "what budget/MCP rules were in effect at session start"
1274
1274
  # without joining time-windowed policy state.
1275
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
1276
1281
  iv = _collect_interceptor_versions()
1277
1282
  if iv:
1278
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,19 @@ 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
+ )
236
334
 
237
335
 
238
336
  # Default directive grace period (ms): the window an agent gets to wind down
@@ -4,10 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flightdeck-sensor"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "In-process agent observability sensor for Flightdeck"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
+ authors = [
12
+ { name = "Omri Arnon", email = "omri.arnon@gmail.com" },
13
+ ]
11
14
  requires-python = ">=3.10,<3.14"
12
15
  dependencies = [
13
16
  "pydantic>=2.0",
@@ -24,6 +27,9 @@ classifiers = [
24
27
  "Typing :: Typed",
25
28
  ]
26
29
 
30
+ [project.urls]
31
+ Repository = "https://github.com/flightdeckhq/flightdeck"
32
+
27
33
  [project.optional-dependencies]
28
34
  anthropic = ["anthropic>=0.20"]
29
35
  openai = ["openai>=1.0", "tiktoken>=0.5"]
@@ -0,0 +1,235 @@
1
+ """H-01 — control-plane egress URL validation.
2
+
3
+ The sensor rejects non-HTTP(S) schemes outright and refuses plaintext
4
+ ``http://`` to a non-local control plane unless the operator explicitly
5
+ opts in (``allow_insecure_transport`` / ``FLIGHTDECK_ALLOW_INSECURE_TRANSPORT``).
6
+ Local / loopback / private hosts are always allowed over http. When an
7
+ env var overrides an explicitly-passed ``server`` / ``api_url``, init()
8
+ warns naming both values (silent override is the vuln).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from contextlib import contextmanager
15
+ from typing import TYPE_CHECKING
16
+
17
+ import pytest
18
+
19
+ import flightdeck_sensor
20
+ from flightdeck_sensor.core.types import SensorConfig, validate_egress_url
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator
24
+
25
+
26
+ @contextmanager
27
+ def _clean_env() -> Iterator[None]:
28
+ saved = {k: v for k, v in os.environ.items() if k.startswith(("FLIGHTDECK_", "AGENT_"))}
29
+ for k in list(saved):
30
+ os.environ.pop(k, None)
31
+ try:
32
+ yield
33
+ finally:
34
+ for k in [k for k in os.environ if k.startswith(("FLIGHTDECK_", "AGENT_"))]:
35
+ os.environ.pop(k, None)
36
+ os.environ.update(saved)
37
+
38
+
39
+ @contextmanager
40
+ def _no_session() -> Iterator[None]:
41
+ flightdeck_sensor._session = None
42
+ flightdeck_sensor._client = None
43
+ try:
44
+ yield
45
+ finally:
46
+ if flightdeck_sensor._session is not None:
47
+ try:
48
+ flightdeck_sensor.teardown()
49
+ except Exception:
50
+ flightdeck_sensor._session = None
51
+ flightdeck_sensor._client = None
52
+
53
+
54
+ # ------------------------------------------------------------------
55
+ # Helper-level (validate_egress_url) tests
56
+ # ------------------------------------------------------------------
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "url",
61
+ [
62
+ "file:///etc/passwd",
63
+ "ftp://example.com/x",
64
+ "gopher://example.com",
65
+ "ws://example.com/ingest",
66
+ "example.com/ingest", # scheme-less
67
+ "", # empty
68
+ ],
69
+ )
70
+ def test_rejects_non_http_schemes(url: str) -> None:
71
+ with pytest.raises(ValueError, match="http:// or https://"):
72
+ validate_egress_url(url, field_name="server")
73
+
74
+
75
+ @pytest.mark.parametrize(
76
+ "url",
77
+ [
78
+ "http://localhost:9999/ingest",
79
+ "http://localhost/ingest",
80
+ "http://sub.localhost/ingest",
81
+ "http://127.0.0.1:4000/ingest",
82
+ "http://127.5.5.5/ingest",
83
+ "http://10.0.0.4/ingest",
84
+ "http://192.168.1.10:4000/ingest",
85
+ "http://172.16.0.1/ingest",
86
+ "http://[::1]/ingest",
87
+ ],
88
+ )
89
+ def test_allows_http_for_local_and_private_hosts(url: str) -> None:
90
+ # Must not raise.
91
+ validate_egress_url(url, field_name="server")
92
+
93
+
94
+ @pytest.mark.parametrize(
95
+ "url",
96
+ [
97
+ "http://stack.internal/ingest",
98
+ "http://example.com/ingest",
99
+ "http://8.8.8.8/ingest",
100
+ ],
101
+ )
102
+ def test_rejects_http_for_non_local_host_without_optin(url: str) -> None:
103
+ with pytest.raises(ValueError, match="insecure http"):
104
+ validate_egress_url(url, field_name="server")
105
+
106
+
107
+ @pytest.mark.parametrize(
108
+ "url",
109
+ [
110
+ "http://stack.internal/ingest",
111
+ "http://example.com/ingest",
112
+ ],
113
+ )
114
+ def test_allows_http_non_local_with_optin(url: str) -> None:
115
+ validate_egress_url(url, field_name="server", allow_insecure_transport=True)
116
+
117
+
118
+ @pytest.mark.parametrize(
119
+ "url",
120
+ [
121
+ "https://stack.internal/ingest",
122
+ "https://example.com/ingest",
123
+ ],
124
+ )
125
+ def test_allows_https_for_any_host(url: str) -> None:
126
+ validate_egress_url(url, field_name="server")
127
+
128
+
129
+ # ------------------------------------------------------------------
130
+ # SensorConfig-level tests
131
+ # ------------------------------------------------------------------
132
+
133
+
134
+ def test_config_rejects_non_local_http_by_default() -> None:
135
+ with pytest.raises(ValueError):
136
+ SensorConfig(server="http://stack.internal/ingest", token="tok")
137
+
138
+
139
+ def test_config_allows_non_local_http_with_optin() -> None:
140
+ cfg = SensorConfig(
141
+ server="http://stack.internal/ingest",
142
+ token="tok",
143
+ allow_insecure_transport=True,
144
+ )
145
+ assert cfg.server == "http://stack.internal/ingest"
146
+
147
+
148
+ def test_config_validates_api_url_too() -> None:
149
+ # server is https (fine) but api_url is a bad scheme → still rejected.
150
+ with pytest.raises(ValueError, match="api_url"):
151
+ SensorConfig(
152
+ server="https://stack.internal/ingest",
153
+ token="tok",
154
+ api_url="ftp://stack.internal/api",
155
+ )
156
+
157
+
158
+ def test_config_defaults_secure_transport_off() -> None:
159
+ cfg = SensorConfig(server="https://x.example/ingest", token="tok")
160
+ assert cfg.allow_insecure_transport is False
161
+
162
+
163
+ # ------------------------------------------------------------------
164
+ # init()-level tests
165
+ # ------------------------------------------------------------------
166
+
167
+
168
+ def test_init_rejects_non_local_http() -> None:
169
+ with _clean_env(), _no_session(), pytest.raises(ValueError):
170
+ flightdeck_sensor.init(
171
+ server="http://stack.internal/ingest",
172
+ token="tok",
173
+ quiet=True,
174
+ )
175
+
176
+
177
+ def test_init_env_flag_enables_insecure(monkeypatch: pytest.MonkeyPatch) -> None:
178
+ with _clean_env(), _no_session():
179
+ monkeypatch.setattr(
180
+ "flightdeck_sensor.core.session.Session.start",
181
+ lambda self: None,
182
+ )
183
+ os.environ["FLIGHTDECK_ALLOW_INSECURE_TRANSPORT"] = "true"
184
+ flightdeck_sensor.init(
185
+ server="http://stack.internal/ingest",
186
+ token="tok",
187
+ quiet=True,
188
+ )
189
+ assert flightdeck_sensor._session is not None
190
+ assert flightdeck_sensor._session.config.allow_insecure_transport is True
191
+
192
+
193
+ def test_init_warns_on_env_server_override(
194
+ monkeypatch: pytest.MonkeyPatch,
195
+ caplog: pytest.LogCaptureFixture,
196
+ ) -> None:
197
+ with _clean_env(), _no_session():
198
+ monkeypatch.setattr(
199
+ "flightdeck_sensor.core.session.Session.start",
200
+ lambda self: None,
201
+ )
202
+ os.environ["FLIGHTDECK_SERVER"] = "http://localhost:5000/ingest"
203
+ with caplog.at_level("WARNING"):
204
+ flightdeck_sensor.init(
205
+ server="http://localhost:9999/ingest",
206
+ token="tok",
207
+ quiet=True,
208
+ )
209
+ # Both the env value and the passed value are named in the warning.
210
+ joined = "\n".join(r.getMessage() for r in caplog.records)
211
+ assert "FLIGHTDECK_SERVER" in joined
212
+ assert "localhost:5000" in joined
213
+ assert "localhost:9999" in joined
214
+ # Env precedence still holds.
215
+ assert flightdeck_sensor._session is not None
216
+ assert flightdeck_sensor._session.config.server == "http://localhost:5000/ingest"
217
+
218
+
219
+ def test_init_no_warn_when_env_matches_or_absent(
220
+ monkeypatch: pytest.MonkeyPatch,
221
+ caplog: pytest.LogCaptureFixture,
222
+ ) -> None:
223
+ with _clean_env(), _no_session():
224
+ monkeypatch.setattr(
225
+ "flightdeck_sensor.core.session.Session.start",
226
+ lambda self: None,
227
+ )
228
+ with caplog.at_level("WARNING"):
229
+ flightdeck_sensor.init(
230
+ server="http://localhost:9999/ingest",
231
+ token="tok",
232
+ quiet=True,
233
+ )
234
+ joined = "\n".join(r.getMessage() for r in caplog.records)
235
+ assert "overrides the server" not in joined
@@ -420,7 +420,12 @@ def test_init_appends_ingest_suffix_when_missing(
420
420
  monkeypatch.delenv("FLIGHTDECK_SERVER", raising=False)
421
421
  monkeypatch.delenv("FLIGHTDECK_TOKEN", raising=False)
422
422
  flightdeck_sensor.teardown()
423
- flightdeck_sensor.init(server="http://stack.internal", token="tok", quiet=True)
423
+ flightdeck_sensor.init(
424
+ server="http://stack.internal",
425
+ token="tok",
426
+ quiet=True,
427
+ allow_insecure_transport=True,
428
+ )
424
429
  try:
425
430
  assert flightdeck_sensor._session is not None
426
431
  assert flightdeck_sensor._session.config.server == "http://stack.internal/ingest"
@@ -445,6 +450,7 @@ def test_init_preserves_ingest_suffix_when_present(
445
450
  server="http://stack.internal/ingest",
446
451
  token="tok",
447
452
  quiet=True,
453
+ allow_insecure_transport=True,
448
454
  )
449
455
  try:
450
456
  assert flightdeck_sensor._session is not None
@@ -472,6 +478,7 @@ def test_init_preserves_ingest_with_trailing_slash(
472
478
  server="http://stack.internal/ingest/",
473
479
  token="tok",
474
480
  quiet=True,
481
+ allow_insecure_transport=True,
475
482
  )
476
483
  try:
477
484
  assert flightdeck_sensor._session is not None
@@ -128,3 +128,51 @@ def test_other_event_types_skip_session_end_close_reason() -> None:
128
128
  s = _make_session()
129
129
  payload = s._build_payload(EventType.POST_CALL)
130
130
  assert "close_reason" not in payload
131
+
132
+
133
+ # ------------------------------------------------------------------
134
+ # C-01 — capture posture on session_start
135
+ # ------------------------------------------------------------------
136
+
137
+
138
+ def _make_session_with_capture(capture: bool) -> Session:
139
+ config = SensorConfig(
140
+ server="http://localhost/ingest",
141
+ token="tok_dev",
142
+ agent_id=str(uuid.uuid4()),
143
+ agent_name="test-agent",
144
+ user_name="test",
145
+ hostname="test-host",
146
+ client_type="flightdeck_sensor",
147
+ api_url="http://localhost/api",
148
+ agent_flavor="e2e-test",
149
+ agent_type="coding",
150
+ session_id=str(uuid.uuid4()),
151
+ capture_prompts=capture,
152
+ quiet=True,
153
+ )
154
+ s = Session(config, client=MagicMock())
155
+ s.event_queue = MagicMock()
156
+ return s
157
+
158
+
159
+ def test_session_start_carries_capture_prompts_true() -> None:
160
+ s = _make_session_with_capture(True)
161
+ payload = s._build_payload(EventType.SESSION_START)
162
+ assert payload["capture_prompts"] is True
163
+
164
+
165
+ def test_session_start_carries_capture_prompts_false() -> None:
166
+ s = _make_session_with_capture(False)
167
+ payload = s._build_payload(EventType.SESSION_START)
168
+ # Present and correct — the field must ship even when off so the
169
+ # server records the posture authoritatively.
170
+ assert payload["capture_prompts"] is False
171
+
172
+
173
+ def test_session_start_capture_prompts_default_false() -> None:
174
+ """The default _make_session helper (no capture kwarg) reflects the
175
+ sensor's False default posture on session_start."""
176
+ s = _make_session()
177
+ payload = s._build_payload(EventType.SESSION_START)
178
+ assert payload["capture_prompts"] is False