Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 96 additions & 2 deletions agents/langchain-deepagents-code/dcode-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ run_dcode() {
# ends in a credential keyword (_KEY, _TOKEN, _SECRET, _PASSWORD,
# _PASSWD, _PASS, _CREDENTIAL) and the value is at least 10 chars (mirroring
# CONTEXT_PATTERNS minimum length).
# * OTLP endpoint variables (OTEL_EXPORTER_OTLP_ENDPOINT and its _TRACES_
# variant) carry a collector URL, not a credential, so the documented
# `--observability` flow can set one. is_safe_otlp_endpoint_url accepts
# ONLY a strict scheme://host[:port][/path] ASCII URL and refuses userinfo,
# query, fragment, percent-encoding, controls, non-ASCII, and oversized
# inputs (a value that cannot smuggle a credential in any field); the
# is_secret_shaped_value scan still runs first. The `_HEADERS` variants
# remain under the name-context refusal because they do carry auth material.
# * Managed messaging values (SLACK_BOT_TOKEN, SLACK_APP_TOKEN,
# TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN) are allowed only when the value
# matches the platform-specific token shape AND does not embed a
Expand Down Expand Up @@ -327,7 +335,7 @@ has_credential_name_context() {
LANGSMITH_RUNS_ENDPOINTS | LANGCHAIN_RUNS_ENDPOINTS)
return 0
;;
OTEL_EXPORTER_OTLP_ENDPOINT | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | OTEL_EXPORTER_OTLP_HEADERS | OTEL_EXPORTER_OTLP_TRACES_HEADERS)
OTEL_EXPORTER_OTLP_HEADERS | OTEL_EXPORTER_OTLP_TRACES_HEADERS)
return 0
;;
*_API_KEY | *_KEY | *_TOKEN | *_SECRET | *_PASSWORD | *_PASSWD | *_PASS | *_CREDENTIAL | *-API-KEY | *-KEY | *-TOKEN | *-SECRET | *-PASSWORD | *-PASSWD | *-PASS | *-CREDENTIAL)
Expand All @@ -351,6 +359,69 @@ is_allowed_openshell_runtime_value() {
[ "$name" = "OPENSHELL_TLS_KEY" ] && [ "$value" = "$OPENSHELL_TLS_KEY_PATH" ]
}

# OTLP endpoint variables carry the collector URL, not a credential. The
# documented `--observability` flow sets one (e.g.
# http://host.openshell.internal:4318), so a clean bare http(s) URL must be
# accepted rather than refused on length like a credential-named var. The
# `_HEADERS` variants (which do carry auth material) stay under the generic
# name-context refusal; only the `_ENDPOINT` variants get this URL allowance.
is_otlp_endpoint_name() {
case "$1" in
OTEL_EXPORTER_OTLP_ENDPOINT | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) return 0 ;;
esac
return 1
}

# The only OTLP collector a managed sandbox can reach: the `--observability`
# egress preset opens exactly this host, and the runtime hardcodes it. Restricting
# to it (exact match, so no subdomain/suffix confusion) sidesteps DNS/IPv4/port
# validation drift between Bash and Python and refuses every unreachable or
# credential-smuggling host by construction (#6538 review).
readonly OTLP_MANAGED_ENDPOINT_HOST="host.openshell.internal"

# Accept ONLY http(s)://host.openshell.internal[:port][/path], where port is a
# 1..65535 decimal with no leading zero and path uses a strict ASCII charset.
# Everything else — any other host, userinfo (@), query (?), fragment (#),
# percent-encoding (%), C0 controls, DEL, non-ASCII, backslashes, whitespace,
# malformed host/port, or oversized input — is refused. The value-shape scan
# (is_secret_shaped_value) still runs first. LC_ALL=C forces byte-wise ASCII so
# UTF-8 collation cannot fold non-ASCII into [A-Za-z0-9]; the managed Python
# runtime's _is_safe_otlp_endpoint_url mirrors this logic byte-for-byte.
# The optional path may contain dot segments; that is intentional and safe here
# because the path is delivered verbatim only to the exact managed collector
# host and cannot traverse to another origin, so there is nothing to smuggle to.
is_safe_otlp_endpoint_url() {
local value="$1" rest authority host port
local LC_ALL=C
[ "${#value}" -le 2048 ] || return 1
case "$value" in
http://*) rest="${value#http://}" ;;
https://*) rest="${value#https://}" ;;
*) return 1 ;;
esac
authority="${rest%%/*}"
if [ "$authority" != "$rest" ]; then
[[ "/${rest#*/}" =~ ^/[A-Za-z0-9._/-]*$ ]] || return 1
fi
host="${authority%%:*}"
[ "$host" = "$OTLP_MANAGED_ENDPOINT_HOST" ] || return 1
if [ "$host" != "$authority" ]; then
port="${authority#*:}"
[[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] && [ "$port" -le 65535 ] || return 1
fi
return 0
}

# True if the value carries any C0 control (0x01-0x1F) or DEL (0x7F). NUL cannot
# reach here — Bash drops it from a variable at read time — so it is out of the
# claimed boundary by construction. Used to fail closed on dotenv OTLP values
# before the generic trim/unquote could silently strip a smuggled trailing
# TAB/VT/FF/CR (#6538 review). LC_ALL=C makes [[:cntrl:]] a byte-wise ASCII class.
has_control_char() {
local LC_ALL=C
[[ "$1" =~ [[:cntrl:]] ]]
}

is_dynamic_dotenv_value() {
local value="$1"
case "$value" in
Expand Down Expand Up @@ -445,6 +516,14 @@ assert_no_secret_runtime_env() {
if is_secret_shaped_value "$value"; then
refuse_secret_env "runtime environment variable" "$name"
fi
if is_otlp_endpoint_name "$name"; then
# An empty value is treated as unset (matches the length check it
# replaces and the managed Python runtime), so only scan a set value.
if [ -n "$value" ] && ! is_safe_otlp_endpoint_url "$value"; then
refuse_secret_env "runtime environment variable" "$name"
fi
continue
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand All @@ -455,7 +534,7 @@ assert_no_secret_env_file() {
local env_file="$DEEPAGENTS_ENV_FILE"
[ -r "$env_file" ] || return 0
local -a lines=()
local env_file_content line key value
local env_file_content line key value raw_value
# Scan the whole file before line parsing so raw multiline blocks cannot put
# their begin and end markers on different physical dotenv lines.
env_file_content="$(<"$env_file")"
Expand All @@ -480,6 +559,10 @@ assert_no_secret_env_file() {
key="${line%%=*}"
[ "$key" != "$line" ] || continue
value="${line#*=}"
# Preserve the value as written (still quoted, untrimmed) so the OTLP guard
# can fail closed on smuggled control characters before normalization strips
# them. The benign CRLF line terminator was already removed above.
raw_value="$value"
key="$(trim_whitespace "$key")"
value="$(trim_whitespace "$value")"
case "$value" in
Expand Down Expand Up @@ -508,6 +591,17 @@ assert_no_secret_env_file() {
if is_secret_shaped_value "$value"; then
refuse_secret_env "$env_file" "$key"
fi
if is_otlp_endpoint_name "$key"; then
# Fail closed on control characters carried in the raw dotenv value before
# the trim/unquote above could silently strip a trailing TAB/VT/FF/CR.
if has_control_char "$raw_value"; then
refuse_secret_env "$env_file" "$key"
fi
if [ -n "$value" ] && ! is_safe_otlp_endpoint_url "$value"; then
refuse_secret_env "$env_file" "$key"
fi
continue
fi
if has_credential_name_context "$key" && [ ${#value} -ge 10 ]; then
refuse_secret_env "$env_file" "$key"
fi
Expand Down
59 changes: 57 additions & 2 deletions agents/langchain-deepagents-code/managed-dcode-runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,27 @@
_CREDENTIAL_ENV_NAMES = {
"LANGSMITH_RUNS_ENDPOINTS",
"LANGCHAIN_RUNS_ENDPOINTS",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
}
# OTLP endpoint variables carry the collector URL, not a credential. The
# documented `--observability` flow sets one (e.g.
# http://host.openshell.internal:4318), so a clean bare http(s) URL is allowed;
# a value with userinfo or a structured key-bearing blob is still refused. The
# `_HEADERS` variants stay in _CREDENTIAL_ENV_NAMES because they carry auth
# material. Mirrors dcode-wrapper.sh is_otlp_endpoint_name (#6466).
_OTLP_ENDPOINT_ENV_NAMES = {
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
}
# The only OTLP collector a managed sandbox can reach: the observability egress
# preset opens exactly this host. Restricting to it (exact match) refuses every
# other host, userinfo, query, fragment, percent-encoding, control character,
# non-ASCII byte, and malformed host/port by construction, and keeps trivial
# parity with the Bash wrapper's is_safe_otlp_endpoint_url (#6538 review).
_OTLP_MANAGED_ENDPOINT_HOST = "host.openshell.internal"
_OTLP_ENDPOINT_PORT = re.compile(r"[1-9][0-9]{0,4}")
_OTLP_ENDPOINT_PATH = re.compile(r"/[A-Za-z0-9._/-]*")
# 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 = (
Expand Down Expand Up @@ -232,6 +248,36 @@ def _is_managed_value(name: str, value: str) -> bool:
return False


def _is_safe_otlp_endpoint_url(value: str) -> bool:
"""Accept ONLY http(s)://host.openshell.internal[:port][/path].

The managed sandbox's observability egress reaches only that host, so an
exact-host allowlist refuses every other host, userinfo, query, fragment,
percent-encoding, control character, non-ASCII byte, malformed host/port,
and oversized input by construction. Mirrors the Bash wrapper's
is_safe_otlp_endpoint_url byte-for-byte (#6538 review). The optional path may
contain dot segments; that is intentional and safe because the path reaches
only the exact managed collector host and cannot traverse to another origin.
"""
if len(value) > 2048:
return False
for scheme in ("http://", "https://"):
if value.startswith(scheme):
rest = value[len(scheme) :]
break
else:
return False
authority, sep, path = rest.partition("/")
if sep and not _OTLP_ENDPOINT_PATH.fullmatch("/" + path):
return False
host, colon, port = authority.partition(":")
if host != _OTLP_MANAGED_ENDPOINT_HOST:
return False
if colon and not (_OTLP_ENDPOINT_PORT.fullmatch(port) and int(port) <= 65535):
return False
return True


def _assert_safe_environment() -> None:
for name, value in os.environ.items():
if _OPENSHELL_ENV_PLACEHOLDER_PREFIX in value:
Expand All @@ -256,6 +302,15 @@ def _assert_safe_environment() -> None:
f"runtime environment variable {name} contains a credential; "
"use NemoClaw credential handling"
)
if (
name.upper() in _OTLP_ENDPOINT_ENV_NAMES
and value
and not _is_safe_otlp_endpoint_url(value)
):
raise RuntimeError(
f"runtime environment variable {name} contains a credential; "
"use NemoClaw credential handling"
)


def _assert_safe_auth_state() -> None:
Expand Down
53 changes: 52 additions & 1 deletion test/langchain-deepagents-code-direct-module-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ check("disabled", False)
["SLACK_BOT_TOKEN", "xoxb-sk-abcdefghijklmnopqrstuv"],
["LANGSMITH_RUNS_ENDPOINTS", '{"https://trace.example":"opaque-key-value"}'],
["LANGCHAIN_RUNS_ENDPOINTS", '{"https://trace.example":"opaque-key-value"}'],
["OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector.example/v1/traces"],
// A plain OTLP endpoint URL is allowed (#6466); credential-bearing forms
// (embedded userinfo, structured key blob) are still refused.
["OTEL_EXPORTER_OTLP_ENDPOINT", "http://token@collector.example:4318"],
["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", '{"https://trace.example":"opaque-key-value"}'],
["OTEL_EXPORTER_OTLP_HEADERS", "authorization=opaque-value"],
]) {
const result = spawnSync("python3", ["-m", "deepagents_code"], {
Expand All @@ -352,6 +355,54 @@ check("disabled", False)
}
});

it("allows the managed OTLP collector URL in the direct-module runtime (#6466)", () => {
const tempDir = createPackageFixture();
patchFixture(tempDir);
for (const name of ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"]) {
for (const value of [
"http://host.openshell.internal:4318",
"http://host.openshell.internal:4318/v1/traces",
"http://host.openshell.internal",
]) {
const result = spawnSync("python3", ["-m", "deepagents_code"], {
env: { PATH: process.env.PATH, PYTHONPATH: tempDir, [name]: value },
encoding: "utf8",
});
expect(result.status, `${name}=${value} was rejected: ${result.stderr}`).toBe(0);
expect(result.stdout).toContain("managed-posture-ok");
}
}
});

it("rejects fail-open OTLP endpoint values in the direct-module runtime (#6538)", () => {
const tempDir = createPackageFixture();
patchFixture(tempDir);
for (const name of ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"]) {
for (const value of [
"https://collector.example.com:4318",
"http://evil.host.openshell.internal:4318",
"http://host.openshell.internal.evil.com",
"http://host.openshell.internal:0",
"http://host.openshell.internal:65536",
"http://999.999.999.999:4318",
"http://host.openshell.internal:4318?x=sk%2Dabcdefghij",
"http://host.openshell.internal:4318?apikey=opaquevalue12345",
"http://token@host.openshell.internal:4318",
"http://host.openshell.internal:4318#fragment",
"http://héllo:4318",
"http://",
`http://host.openshell.internal:4318/p${"a".repeat(3000)}`,
]) {
const result = spawnSync("python3", ["-m", "deepagents_code"], {
env: { PATH: process.env.PATH, PYTHONPATH: tempDir, [name]: value },
encoding: "utf8",
});
expect(result.status, `${name}=${value} was allowed`).not.toBe(0);
expect(result.stderr).toContain(`runtime environment variable ${name}`);
}
}
});

it("allows only scoped managed credential-shaped runtime values", () => {
const tempDir = createPackageFixture();
patchFixture(tempDir);
Expand Down
87 changes: 87 additions & 0 deletions test/langchain-deepagents-code-image-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,93 @@ describe("LangChain Deep Agents Code image credential boundary", () => {
}
});

it("pins the OTLP endpoint accept/refuse contract on runtime and dotenv paths (#6466, #6538)", () => {
// The managed collector URL is not a credential and must pass; everything
// else refuses with the full contract. The #6538 review requires exact
// status 2, the variable name present, the rejected value absent (no echo),
// no run, across both endpoint names and both the runtime and dotenv paths.
const endpointNames = ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"];
const acceptUrls = [
"http://host.openshell.internal:4318",
"http://host.openshell.internal:4318/v1/traces",
"http://host.openshell.internal",
];
const rejectValues = [
"https://collector.example.com:4318", // non-managed host
"http://evil.host.openshell.internal:4318", // subdomain confusion
"http://host.openshell.internal.evil.com", // suffix confusion
"http://host.openshell.internal:0", // port 0
"http://host.openshell.internal:65536", // port out of range
"http://token@host.openshell.internal:4318", // userinfo
"http://host.openshell.internal:4318?x=sk%2Dabcdefghij", // percent-encoded token
"http://host.openshell.internal:4318?apikey=opaquevalue12345", // opaque query cred
"http://host.openshell.internal:4318#fragment", // fragment
"http://999.999.999.999:4318", // invalid IPv4
"http://héllo:4318", // non-ASCII host
'{"https://trace.example":"opaque-key-value"}', // structured blob
"http://", // hostless
];

const mk = (tag: string) => makeWrapperFixture(fs.mkdtempSync(path.join(os.tmpdir(), tag)));
for (const name of endpointNames) {
for (const url of acceptUrls) {
const rt = mk("nemoclaw-dcode-otlp-ok-rt-");
expect(
runWrapper(rt.wrapperPath, ["-n", "hi"], { [name]: url }).status,
`rt ${name}=${url}`,
).toBe(0);
expect(fs.existsSync(rt.ranMarker)).toBe(true);
const dv = mk("nemoclaw-dcode-otlp-ok-dv-");
fs.writeFileSync(dv.envFile, `${name}=${url}\n`, "utf8");
expect(runWrapper(dv.wrapperPath, ["-n", "hi"], {}).status, `dv ${name}=${url}`).toBe(0);
expect(fs.existsSync(dv.ranMarker)).toBe(true);
}

for (const value of rejectValues) {
const rt = mk("nemoclaw-dcode-otlp-bad-rt-");
const rtRes = runWrapper(rt.wrapperPath, ["-n", "hi"], { [name]: value });
expect(rtRes.status, `rt ${name}=${value}`).toBe(2);
expect(rtRes.stderr).toContain(name);
expect(rtRes.stderr).not.toContain(value);
expect(fs.existsSync(rt.ranMarker)).toBe(false);

const dv = mk("nemoclaw-dcode-otlp-bad-dv-");
fs.writeFileSync(dv.envFile, `${name}=${value}\n`, "utf8");
const dvRes = runWrapper(dv.wrapperPath, ["-n", "hi"], {});
expect(dvRes.status, `dv ${name}=${value}`).toBe(2);
expect(dvRes.stderr).toContain(name);
expect(dvRes.stderr).not.toContain(value);
expect(fs.existsSync(dv.ranMarker)).toBe(false);
}

// Empty value is treated as unset on both paths.
const ert = mk("nemoclaw-dcode-otlp-empty-rt-");
expect(
runWrapper(ert.wrapperPath, ["-n", "hi"], { [name]: "" }).status,
`rt ${name}=empty`,
).toBe(0);
expect(fs.existsSync(ert.ranMarker)).toBe(true);
const edv = mk("nemoclaw-dcode-otlp-empty-dv-");
fs.writeFileSync(edv.envFile, `${name}=\n`, "utf8");
expect(runWrapper(edv.wrapperPath, ["-n", "hi"], {}).status, `dv ${name}=empty`).toBe(0);
expect(fs.existsSync(edv.ranMarker)).toBe(true);

// Control characters in a dotenv value fail closed before trim/unquote
// could strip a smuggled trailing TAB/VT/FF/ESC/CR (#6538).
for (const ctrl of ["\t", "\x0b", "\x0c", "\x1b", "\r"]) {
const dv = mk("nemoclaw-dcode-otlp-ctrl-");
fs.writeFileSync(
dv.envFile,
`${name}="http://host.openshell.internal:4318${ctrl}"\n`,
"utf8",
);
const res = runWrapper(dv.wrapperPath, ["-n", "hi"], {});
expect(res.status, `dv ${name} ctrl=${JSON.stringify(ctrl)}`).toBe(2);
expect(fs.existsSync(dv.ranMarker)).toBe(false);
}
}
});

it("rejects mismatched, malformed, wrapped, and raw credential placeholders", () => {
const invalidCases = [
{ name: "MODEL_NAME", value: "openshell:resolve:env:OTHER_NAME" },
Expand Down
Loading