diff --git a/agents/langchain-deepagents-code/dcode-wrapper.sh b/agents/langchain-deepagents-code/dcode-wrapper.sh index 70c99dbb2a..06812fdfe7 100755 --- a/agents/langchain-deepagents-code/dcode-wrapper.sh +++ b/agents/langchain-deepagents-code/dcode-wrapper.sh @@ -119,6 +119,10 @@ run_dcode() { # TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN) are allowed only when the value # matches the platform-specific token shape AND does not embed a # non-platform canonical secret prefix. +# * Local OTLP collector endpoints are allowed only for OTLP endpoint names, +# only as canonical HTTP URLs to loopback/OpenShell host aliases on +# standard OTLP ports, and never for OTLP headers or other credential +# names. # * The env-file parser strips a leading `export ` keyword (mirroring # python-dotenv) and rejects values containing dotenv expansion ($VAR, # ${VAR}), command substitution ($(...) or backticks), because upstream @@ -351,6 +355,59 @@ is_allowed_openshell_runtime_value() { [ "$name" = "OPENSHELL_TLS_KEY" ] && [ "$value" = "$OPENSHELL_TLS_KEY_PATH" ] } +is_local_otlp_endpoint_name() { + case "$1" in + OTEL_EXPORTER_OTLP_ENDPOINT | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) + return 0 + ;; + esac + return 1 +} + +is_allowed_local_otlp_endpoint_value() { + local value="$1" + local rest authority host port path + case "$value" in + http://*) rest="${value#http://}" ;; + *) return 1 ;; + esac + case "$rest" in + "" | *[[:space:]]* | *\'* | *\"* | *\\* | *\?* | *\#* | *%* | *@*) + return 1 + ;; + esac + authority="${rest%%/*}" + [ -n "$authority" ] || return 1 + path="${rest#"$authority"}" + if [[ "$authority" == \[::1\]:* ]]; then + host="::1" + port="${authority##*:}" + else + case "$authority" in + *:*) + host="${authority%%:*}" + port="${authority#*:}" + ;; + *) + return 1 + ;; + esac + fi + [ "$port" = "4317" ] || [ "$port" = "4318" ] || return 1 + case "$host" in + localhost | 127.0.0.1 | ::1 | host.openshell.internal | host.docker.internal | host.containers.internal) ;; + *) + return 1 + ;; + esac + case "$path" in + "" | / | /v1/traces) + return 0 + ;; + esac + return 1 +} + is_dynamic_dotenv_value() { local value="$1" case "$value" in @@ -445,6 +502,9 @@ assert_no_secret_runtime_env() { if is_secret_shaped_value "$value"; then refuse_secret_env "runtime environment variable" "$name" fi + if is_local_otlp_endpoint_name "$name" && is_allowed_local_otlp_endpoint_value "$value"; then + continue + fi if has_credential_name_context "$name" && [ ${#value} -ge 10 ] && ! is_allowed_openshell_runtime_value "$name" "$value"; then refuse_secret_env "runtime environment variable" "$name" fi diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index c6760db1fe..c3919553fd 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -51,6 +51,20 @@ "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS", } +_LOCAL_OTLP_ENDPOINT_ENV_NAMES = { + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", +} +_LOCAL_OTLP_ENDPOINT_HOSTS = { + "localhost", + "127.0.0.1", + "::1", + "host.openshell.internal", + "host.docker.internal", + "host.containers.internal", +} +_LOCAL_OTLP_ENDPOINT_PORTS = {4317, 4318} +_LOCAL_OTLP_ENDPOINT_PATHS = {"", "/", "/v1/traces"} # Python's \s also includes control separators that ECMAScript excludes, so # spell out the canonical whitespace set for cross-runtime parity. _ECMASCRIPT_NON_WHITESPACE_SECRET_CHAR = ( @@ -213,6 +227,44 @@ def _is_managed_value(name: str, value: str) -> bool: return False +def _is_allowed_local_otlp_endpoint(name: str, value: str) -> bool: + if name.upper() not in _LOCAL_OTLP_ENDPOINT_ENV_NAMES: + return False + if not value.isascii() or any( + character.isspace() + or ord(character) < 32 + or ord(character) == 127 + for character in value + ): + return False + if any(character in value for character in ("%", "\\", "'", '"')): + return False + parsed = urlsplit(value) + if ( + parsed.scheme != "http" + or not value.startswith("http://") + or not parsed.netloc + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in _LOCAL_OTLP_ENDPOINT_PATHS + ): + return False + try: + port = parsed.port + except ValueError: + return False + if ( + port not in _LOCAL_OTLP_ENDPOINT_PORTS + or parsed.hostname not in _LOCAL_OTLP_ENDPOINT_HOSTS + ): + return False + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + return value == f"http://{host}:{port}{parsed.path}" + + def _assert_safe_environment() -> None: for name, value in os.environ.items(): if _OPENSHELL_ENV_PLACEHOLDER_PREFIX in value: @@ -224,14 +276,22 @@ def _assert_safe_environment() -> None: ) if _is_managed_value(name, value): continue - if _contains_secret_shape(value) or ( - len(value) >= 10 - and ( - _CREDENTIAL_NAME.search(name) - or _CREDENTIAL_CAMEL_NAME.search(name) + if _contains_secret_shape(value): + raise RuntimeError( + f"runtime environment variable {name} contains a credential; " + "use NemoClaw credential handling" + ) + if _is_allowed_local_otlp_endpoint(name, value): + continue + if ( + ( + len(value) >= 10 + and ( + _CREDENTIAL_NAME.search(name) + or _CREDENTIAL_CAMEL_NAME.search(name) + ) ) - ) or ( - bool(value) and name.upper() in _CREDENTIAL_ENV_NAMES + or (bool(value) and name.upper() in _CREDENTIAL_ENV_NAMES) ): raise RuntimeError( f"runtime environment variable {name} contains a credential; " diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index 2a4af1afa8..9f9f328db6 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -350,6 +350,35 @@ check("disabled", False) } }); + it("allows direct-module local OTLP endpoint env vars only for endpoint names", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const allowed = spawnSync("python3", ["-m", "deepagents_code"], { + env: { + PATH: process.env.PATH, + PYTHONPATH: tempDir, + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.openshell.internal:4318", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://127.0.0.1:4318/v1/traces", + }, + encoding: "utf8", + }); + + expect(allowed.status, allowed.stderr).toBe(0); + expect(allowed.stdout).toContain("managed-posture-ok"); + + const rejected = spawnSync("python3", ["-m", "deepagents_code"], { + env: { + PATH: process.env.PATH, + PYTHONPATH: tempDir, + CUSTOM_API_KEY: "http://host.openshell.internal:4318", + }, + encoding: "utf8", + }); + + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("runtime environment variable CUSTOM_API_KEY"); + }); + it("allows only scoped managed credential-shaped runtime values", () => { const tempDir = createPackageFixture(); patchFixture(tempDir); diff --git a/test/langchain-deepagents-code-image-credentials.test.ts b/test/langchain-deepagents-code-image-credentials.test.ts index 5754d0bde7..d98a6c55f3 100644 --- a/test/langchain-deepagents-code-image-credentials.test.ts +++ b/test/langchain-deepagents-code-image-credentials.test.ts @@ -27,7 +27,7 @@ describe("LangChain Deep Agents Code image credential boundary", () => { const fakeSecret = "sk-TEST-FAKE-DO-NOT-USE-0000000000000000000000"; const result = runWrapper(wrapperPath, ["-n", "hi"], { OPENAI_API_KEY: fakeSecret }); - expect(result.status).not.toBe(0); + expect(result.status).toBe(2); expect(result.stderr).toContain("OPENAI_API_KEY"); expect(result.stderr).not.toContain(fakeSecret); expect(result.stderr).toContain("nemoclaw credentials"); @@ -35,6 +35,52 @@ describe("LangChain Deep Agents Code image credential boundary", () => { expect(fs.existsSync(ranMarker)).toBe(false); }); + it.each([ + { name: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://host.openshell.internal:4318" }, + { name: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://localhost:4317/" }, + { + name: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + value: "http://127.0.0.1:4318/v1/traces", + }, + { + name: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + value: "http://[::1]:4318/v1/traces", + }, + ])("allows local OTLP endpoint env var $name=$value", ({ name, value }) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-local-otel-")); + const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir); + const result = runWrapper(wrapperPath, ["-n", "hi"], { [name]: value }); + + expect(result.status, `${name}=${value}`).toBe(0); + expect(result.stdout).toContain("dcode-stub-ran"); + expect(result.stderr).not.toContain("refusing to start"); + expect(fs.existsSync(ranMarker)).toBe(true); + }); + + it.each([ + { name: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "https://collector.example/v1/traces" }, + { name: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://host.openshell.internal:9999" }, + { + name: "OTEL_EXPORTER_OTLP_ENDPOINT", + value: "http://host.openshell.internal:4318/v1/traces?api_key=opaque", + }, + { + name: "OTEL_EXPORTER_OTLP_ENDPOINT", + value: "http://token@host.openshell.internal:4318", + }, + { name: "OTEL_EXPORTER_OTLP_HEADERS", value: "authorization=opaque-value" }, + { name: "CUSTOM_API_KEY", value: "http://host.openshell.internal:4318" }, + ])("rejects non-local or non-endpoint OTLP env var $name=$value", ({ name, value }) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-otel-reject-")); + const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir); + const result = runWrapper(wrapperPath, ["-n", "hi"], { [name]: value }); + + expect(result.status, `${name}=${value}`).toBe(2); + expect(result.stderr).toContain(name); + expect(result.stderr).not.toContain(value); + expect(fs.existsSync(ranMarker)).toBe(false); + }); + it("rejects secret-shaped values written to the deepagents env file", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-wrapper-")); const { wrapperPath, ranMarker, envFile } = makeWrapperFixture(tempDir);