From 993d63323e833029e960548485a219ac7edb7890 Mon Sep 17 00:00:00 2001 From: Raja Sekhar Rao Dheekonda Date: Thu, 11 Jun 2026 13:52:59 -0700 Subject: [PATCH 1/4] fix: skip airt_* kwargs for attacks that don't support them Root cause: _build_attack_params() unconditionally injected airt_assessment_id, airt_goal_category, and airt_target_model into every generated attack call. 12 of 46 SDK attacks (rainbow, gptfuzzer, autodan, renellm, beast, drattack, deep_inception, hopskipjump, simba, nes, zoo, multimodal) don't accept these kwargs and raise TypeError. This caused campaigns with mixed attack types to crash partway through, creating multiple assessments instead of one and leaving the platform with missing attack data. Fix: - Add _ATTACKS_WITHOUT_AIRT_KWARGS set (determined via SDK introspection) - _build_attack_params() checks canonical_name before adding airt_* kwargs - _generate_transform_study() same conditional logic - Category template uses runtime inspect.signature() check since attack functions are dispatched dynamically Tested: campaign with tap+rainbow+beast generates correct kwargs per attack Existing tests pass. --- .../ai-red-teaming/scripts/attack_runner.py | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/capabilities/ai-red-teaming/scripts/attack_runner.py b/capabilities/ai-red-teaming/scripts/attack_runner.py index 55a3204..8df59f6 100644 --- a/capabilities/ai-red-teaming/scripts/attack_runner.py +++ b/capabilities/ai-red-teaming/scripts/attack_runner.py @@ -775,6 +775,25 @@ def _auto_execute_workflow(filename: str, timeout: int = 540) -> str: ATTACK_ALIASES["tmap"] = "tmap_trajectory_attack" ATTACK_ALIASES["aprt"] = "aprt_progressive_attack" +# Attacks that do NOT accept airt_* span-linkage kwargs. +# Determined by inspecting SDK function signatures. When airt_* kwargs are +# absent, the platform may not link traces to the assessment — but passing +# unsupported kwargs causes a hard TypeError crash, which is worse. +_ATTACKS_WITHOUT_AIRT_KWARGS: set[str] = { + "rainbow_attack", + "gptfuzzer_attack", + "autodan_turbo_attack", + "renellm_attack", + "beast_attack", + "drattack", + "deep_inception_attack", + "hopskipjump_attack", + "simba_attack", + "nes_attack", + "zoo_attack", + "multimodal_attack", +} + _TRANSFORM_DEFS: dict[str, dict] = { # encoding "base64_encode": {"module": "dreadnode.transforms.encoding", "name": "base64_encode", "code": "base64_encode()"}, @@ -3144,10 +3163,14 @@ def _build_attack_params( params.append("{}={}".format(k, v)) if transforms_expr is not None: params.append("transforms={}".format(transforms_expr)) - # AIRT span linkage — links Study-created spans to assessment in ClickHouse - params.append("airt_assessment_id=assessment.assessment_id") - params.append("airt_goal_category={}".format(goal_category_expr)) - params.append("airt_target_model=TARGET_MODEL") + # AIRT span linkage — links Study-created spans to assessment in ClickHouse. + # Only pass these kwargs to attacks that accept them; older attacks raise + # TypeError on unknown kwargs (see _ATTACKS_WITHOUT_AIRT_KWARGS). + canon = atk.get("canonical_name", atk.get("function", "")) + if canon not in _ATTACKS_WITHOUT_AIRT_KWARGS: + params.append("airt_assessment_id=assessment.assessment_id") + params.append("airt_goal_category={}".format(goal_category_expr)) + params.append("airt_target_model=TARGET_MODEL") return ",\n ".join(params) @@ -3358,12 +3381,13 @@ def _generate_transform_study(config: dict) -> str: for k, v in atk.get("extra_defaults", {}).items(): params.append("{}={}".format(k, v)) params.append("transforms=transforms") - params.append("airt_assessment_id=assessment.assessment_id") - params.append("airt_goal_category=GOAL_CATEGORY.value") - params.append("airt_target_model=TARGET_MODEL") - attack_params = ",\n ".join(params) - + # Only pass airt_* kwargs to attacks that accept them canon = atk["canonical_name"] + if canon not in _ATTACKS_WITHOUT_AIRT_KWARGS: + params.append("airt_assessment_id=assessment.assessment_id") + params.append("airt_goal_category=GOAL_CATEGORY.value") + params.append("airt_target_model=TARGET_MODEL") + attack_params = ",\n ".join(params) assessment_name = _safe_str(config.get("assessment_name") or "{} Transform Comparison".format(canon)) assessment_kwargs = _build_assessment_kwargs(config, assessment_name, config.get("filename", "")) @@ -3577,6 +3601,17 @@ async def main(): sys.stdout.flush() try: + # Build airt_* kwargs only for attacks that accept them + import inspect as _inspect + _sig = _inspect.signature(attack_fn) + _airt_kw = {{}} + if "airt_assessment_id" in _sig.parameters: + _airt_kw["airt_assessment_id"] = assessment.assessment_id + _airt_kw["airt_goal_category"] = goal_cat.value + _airt_kw["airt_category"] = goal_row.get("category", "") + _airt_kw["airt_sub_category"] = goal_sub + _airt_kw["airt_target_model"] = TARGET_MODEL + study = attack_fn( {attack_params}, ) @@ -3718,11 +3753,9 @@ def _generate_category_attack(config: dict) -> str: if transforms: transforms_expr = "[{}]".format(", ".join(t["code"] for t in transforms)) params.append("transforms={}".format(transforms_expr)) - params.append("airt_assessment_id=assessment.assessment_id") - params.append("airt_goal_category=goal_cat.value") - params.append("airt_category=goal_row.get('category', '')") - params.append("airt_sub_category=goal_sub") - params.append("airt_target_model=TARGET_MODEL") + # Build airt_* kwargs dict conditionally — only pass to attacks that accept them. + # The template uses **_airt_kw which unpacks to nothing for unsupported attacks. + params.append("**_airt_kw") attack_params = ",\n ".join(params) transform_names = [t["resolved_name"] for t in transforms] if transforms else [] From 65fd5595e204852ac26eac4813334fbdd1c60d9f Mon Sep 17 00:00:00 2001 From: Raja Sekhar Rao Dheekonda Date: Thu, 11 Jun 2026 14:05:19 -0700 Subject: [PATCH 2/4] fix: pass airt_* span-linkage kwargs to all attacks unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 46 SDK attacks now accept airt_assessment_id, airt_goal_category, and airt_target_model kwargs (added in dreadnode-tiger#1693). The code-gen passes them unconditionally to every attack — no more _ATTACKS_WITHOUT_AIRT_KWARGS guard needed. This ensures all attacks in a campaign have proper OTEL span linkage to the assessment in ClickHouse, so the platform shows correct findings counts, ASR, and severity for every attack type. Flow for '10 attacks + 5 transforms': - 1 Assessment created - 10 assessment.run() calls (one per attack type) - Each attack gets transforms=[t1,t2,t3,t4,t5] applied - Each attack gets airt_assessment_id for platform span linkage - Platform sees 10 attack spans, each linked to the assessment - Metrics: overall_asr = (C+H+M+L findings) / 10 total attacks Depends on: dreadnode-tiger#1693 (SDK airt_* kwargs) --- capabilities/ai-red-teaming/capability.yaml | 2 +- .../ai-red-teaming/scripts/attack_runner.py | 59 +++++-------------- 2 files changed, 16 insertions(+), 45 deletions(-) diff --git a/capabilities/ai-red-teaming/capability.yaml b/capabilities/ai-red-teaming/capability.yaml index 2c91ea6..82b49fe 100644 --- a/capabilities/ai-red-teaming/capability.yaml +++ b/capabilities/ai-red-teaming/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: ai-red-teaming -version: "1.3.7" +version: "1.4.0" description: > Probe the security and safety of AI applications, agents, and foundation models. Orchestrates adversarial attack workflows to discover vulnerabilities in LLMs, diff --git a/capabilities/ai-red-teaming/scripts/attack_runner.py b/capabilities/ai-red-teaming/scripts/attack_runner.py index 8df59f6..c2a92f3 100644 --- a/capabilities/ai-red-teaming/scripts/attack_runner.py +++ b/capabilities/ai-red-teaming/scripts/attack_runner.py @@ -775,24 +775,7 @@ def _auto_execute_workflow(filename: str, timeout: int = 540) -> str: ATTACK_ALIASES["tmap"] = "tmap_trajectory_attack" ATTACK_ALIASES["aprt"] = "aprt_progressive_attack" -# Attacks that do NOT accept airt_* span-linkage kwargs. -# Determined by inspecting SDK function signatures. When airt_* kwargs are -# absent, the platform may not link traces to the assessment — but passing -# unsupported kwargs causes a hard TypeError crash, which is worse. -_ATTACKS_WITHOUT_AIRT_KWARGS: set[str] = { - "rainbow_attack", - "gptfuzzer_attack", - "autodan_turbo_attack", - "renellm_attack", - "beast_attack", - "drattack", - "deep_inception_attack", - "hopskipjump_attack", - "simba_attack", - "nes_attack", - "zoo_attack", - "multimodal_attack", -} + _TRANSFORM_DEFS: dict[str, dict] = { # encoding @@ -3164,13 +3147,10 @@ def _build_attack_params( if transforms_expr is not None: params.append("transforms={}".format(transforms_expr)) # AIRT span linkage — links Study-created spans to assessment in ClickHouse. - # Only pass these kwargs to attacks that accept them; older attacks raise - # TypeError on unknown kwargs (see _ATTACKS_WITHOUT_AIRT_KWARGS). - canon = atk.get("canonical_name", atk.get("function", "")) - if canon not in _ATTACKS_WITHOUT_AIRT_KWARGS: - params.append("airt_assessment_id=assessment.assessment_id") - params.append("airt_goal_category={}".format(goal_category_expr)) - params.append("airt_target_model=TARGET_MODEL") + # All SDK attacks accept these kwargs as of dreadnode-tiger#1693. + params.append("airt_assessment_id=assessment.assessment_id") + params.append("airt_goal_category={}".format(goal_category_expr)) + params.append("airt_target_model=TARGET_MODEL") return ",\n ".join(params) @@ -3381,12 +3361,11 @@ def _generate_transform_study(config: dict) -> str: for k, v in atk.get("extra_defaults", {}).items(): params.append("{}={}".format(k, v)) params.append("transforms=transforms") - # Only pass airt_* kwargs to attacks that accept them + # AIRT span linkage — all attacks accept these as of dreadnode-tiger#1693 + params.append("airt_assessment_id=assessment.assessment_id") + params.append("airt_goal_category=GOAL_CATEGORY.value") + params.append("airt_target_model=TARGET_MODEL") canon = atk["canonical_name"] - if canon not in _ATTACKS_WITHOUT_AIRT_KWARGS: - params.append("airt_assessment_id=assessment.assessment_id") - params.append("airt_goal_category=GOAL_CATEGORY.value") - params.append("airt_target_model=TARGET_MODEL") attack_params = ",\n ".join(params) assessment_name = _safe_str(config.get("assessment_name") or "{} Transform Comparison".format(canon)) assessment_kwargs = _build_assessment_kwargs(config, assessment_name, config.get("filename", "")) @@ -3601,17 +3580,6 @@ async def main(): sys.stdout.flush() try: - # Build airt_* kwargs only for attacks that accept them - import inspect as _inspect - _sig = _inspect.signature(attack_fn) - _airt_kw = {{}} - if "airt_assessment_id" in _sig.parameters: - _airt_kw["airt_assessment_id"] = assessment.assessment_id - _airt_kw["airt_goal_category"] = goal_cat.value - _airt_kw["airt_category"] = goal_row.get("category", "") - _airt_kw["airt_sub_category"] = goal_sub - _airt_kw["airt_target_model"] = TARGET_MODEL - study = attack_fn( {attack_params}, ) @@ -3753,9 +3721,12 @@ def _generate_category_attack(config: dict) -> str: if transforms: transforms_expr = "[{}]".format(", ".join(t["code"] for t in transforms)) params.append("transforms={}".format(transforms_expr)) - # Build airt_* kwargs dict conditionally — only pass to attacks that accept them. - # The template uses **_airt_kw which unpacks to nothing for unsupported attacks. - params.append("**_airt_kw") + # AIRT span linkage — all attacks accept these as of dreadnode-tiger#1693 + params.append("airt_assessment_id=assessment.assessment_id") + params.append("airt_goal_category=goal_cat.value") + params.append("airt_category=goal_row.get('category', '')") + params.append("airt_sub_category=goal_sub") + params.append("airt_target_model=TARGET_MODEL") attack_params = ",\n ".join(params) transform_names = [t["resolved_name"] for t in transforms] if transforms else [] From 34127aad7f79959b837ed9279286b69489ff09a3 Mon Sep 17 00:00:00 2001 From: Raja Sekhar Rao Dheekonda Date: Fri, 12 Jun 2026 10:40:06 -0700 Subject: [PATCH 3/4] fix: execute_workflow missing platform env + timeout too low for campaigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that caused failures when running large campaigns: 1. execute_workflow didn't pass platform credentials (DREADNODE_SERVER, DREADNODE_API_KEY, etc.) to the workflow subprocess. When the agent called execute_workflow manually after an auto-execute timeout, the workflow script couldn't connect to the platform. Fixed by adding _resolve_platform_env() that reads from saved profile (same logic as attack_runner's auto-execute). 2. Timeout caps were too low for multi-attack campaigns: - execute_workflow: 600s max → 3600s (1 hour) - _auto_execute_workflow: 540s default → 3600s - _call_runner: 660s → 3660s A 12-attack campaign with 10 iterations each easily takes 20+ min. The old 540s cap caused timeouts on the first run, forcing the user to manually re-execute (which then hit Bug 1). --- .../ai-red-teaming/scripts/attack_runner.py | 2 +- capabilities/ai-red-teaming/tools/attacks.py | 2 +- .../ai-red-teaming/tools/workflows.py | 35 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/capabilities/ai-red-teaming/scripts/attack_runner.py b/capabilities/ai-red-teaming/scripts/attack_runner.py index c2a92f3..dada999 100644 --- a/capabilities/ai-red-teaming/scripts/attack_runner.py +++ b/capabilities/ai-red-teaming/scripts/attack_runner.py @@ -95,7 +95,7 @@ def _resolve_platform_env() -> dict[str, str]: return env -def _auto_execute_workflow(filename: str, timeout: int = 540) -> str: +def _auto_execute_workflow(filename: str, timeout: int = 3600) -> str: """Execute a workflow script and return output. Used for auto-execution after generate.""" filepath = WORKFLOWS_DIR / filename if not filepath.exists(): diff --git a/capabilities/ai-red-teaming/tools/attacks.py b/capabilities/ai-red-teaming/tools/attacks.py index 4e5d8da..f6c3dc3 100644 --- a/capabilities/ai-red-teaming/tools/attacks.py +++ b/capabilities/ai-red-teaming/tools/attacks.py @@ -42,7 +42,7 @@ def _call_runner(name: str, params: dict) -> str: input=payload, capture_output=True, text=True, - timeout=660, # 11min: runner has 9min internal timeout + overhead + timeout=3660, # 61min: runner has 60min internal timeout + overhead ) output = result.stdout.strip() if result.returncode != 0: diff --git a/capabilities/ai-red-teaming/tools/workflows.py b/capabilities/ai-red-teaming/tools/workflows.py index 15fb9e3..d8bdebf 100644 --- a/capabilities/ai-red-teaming/tools/workflows.py +++ b/capabilities/ai-red-teaming/tools/workflows.py @@ -26,6 +26,36 @@ from dreadnode.app.env import resolve_python_executable +def _resolve_platform_env() -> dict[str, str]: + """Build env dict with platform credentials for subprocess execution. + + Mirrors attack_runner._resolve_platform_env so manually-executed + workflows get the same credential resolution as auto-executed ones. + """ + env = os.environ.copy() + if env.get("DREADNODE_SERVER") and env.get("DREADNODE_API_KEY"): + return env + if env.get("DREADNODE_LLM_BASE") and env.get("DREADNODE_LLM_API_KEY"): + return env + try: + import yaml + config_path = Path.home() / ".dreadnode" / "config.yaml" + if config_path.exists(): + config = yaml.safe_load(config_path.read_text()) + active = config.get("active") + servers = config.get("servers", {}) + if active and active in servers: + profile = servers[active] + env.setdefault("DREADNODE_SERVER", profile.get("url", "")) + env.setdefault("DREADNODE_API_KEY", profile.get("api_key", "")) + env.setdefault("DREADNODE_ORGANIZATION", profile.get("default_organization", "")) + env.setdefault("DREADNODE_WORKSPACE", profile.get("default_workspace", "")) + env.setdefault("DREADNODE_PROJECT", profile.get("default_project", "")) + except Exception: + pass + return env + + # Get org/workspace from active profile, with fallbacks def _get_workspace_path() -> Path: try: @@ -165,7 +195,7 @@ def list_workflows() -> str: @safe_tool def execute_workflow( filename: t.Annotated[str, "Workflow filename to execute"], - timeout: t.Annotated[int, "Max execution time in seconds (max 600)"] = 540, + timeout: t.Annotated[int, "Max execution time in seconds (max 3600)"] = 540, ) -> str: """Execute a saved attack workflow script. @@ -179,7 +209,7 @@ def execute_workflow( if not filepath.exists(): return f"Workflow not found: {filename}. Use list_workflows to see available." - timeout = min(timeout, 600) + timeout = min(timeout, 3600) try: python_executable = resolve_python_executable() @@ -193,6 +223,7 @@ def execute_workflow( text=True, timeout=timeout, cwd=str(WORKFLOWS_DIR.parent), + env=_resolve_platform_env(), ) output = result.stdout if result.stderr: From 5b9412285ffae4036889f4c2f1321d2270cfb2a4 Mon Sep 17 00:00:00 2001 From: Raja Sekhar Rao Dheekonda Date: Fri, 12 Jun 2026 10:56:38 -0700 Subject: [PATCH 4/4] fix: use SDK UserConfig for env resolution instead of raw YAML parsing The previous _resolve_platform_env() had two issues: 1. Early return on DREADNODE_LLM_BASE: In TUI mode, the runtime sets LLM proxy vars but NOT server/org/workspace/project. The old code returned early when LLM vars were set, skipping the profile read. Result: subprocess had no org/workspace/project scope, so assessments landed in the wrong project or failed to register. 2. Raw YAML parsing: Manually parsed ~/.dreadnode/config.yaml instead of using the SDK's UserConfig.read(). This missed workspace/project switches done via /workspace in the TUI (which updates the profile object but the YAML key names may differ). Fix: Always read the active profile via UserConfig (which respects dn login, /workspace, /profile switches). Use setdefault() so explicit env vars (sandbox mode) still take precedence. --- .../ai-red-teaming/scripts/attack_runner.py | 59 +++++++++---------- .../ai-red-teaming/tools/workflows.py | 34 +++++------ 2 files changed, 45 insertions(+), 48 deletions(-) diff --git a/capabilities/ai-red-teaming/scripts/attack_runner.py b/capabilities/ai-red-teaming/scripts/attack_runner.py index dada999..42f0f8c 100644 --- a/capabilities/ai-red-teaming/scripts/attack_runner.py +++ b/capabilities/ai-red-teaming/scripts/attack_runner.py @@ -55,42 +55,39 @@ def _get_workspace_path() -> Path: def _resolve_platform_env() -> dict[str, str]: """Build env dict with platform credentials for subprocess execution. - In sandbox mode, env vars are already set. In TUI/CLI mode, reads from - the saved profile at ~/.cache/dreadnode/config.yaml. + In sandbox mode, env vars are already set (DREADNODE_SERVER + API_KEY). + In TUI mode, the runtime sets DREADNODE_LLM_BASE + LLM_API_KEY for the + LLM proxy but does NOT set SERVER/API_KEY/ORG/WORKSPACE/PROJECT — those + come from the saved profile (``dn login``). + + We ALWAYS read the saved profile to fill in scope vars (org, workspace, + project) so the subprocess respects the user's TUI context. ``setdefault`` + ensures explicit env vars (sandbox) take precedence over the profile. """ env = os.environ.copy() - # If the runtime already provides platform credentials in any of the - # forms the SDK understands, pass the env through untouched -- the - # generated script self-configures via dn.configure(), whose precedence - # is: explicit args > env vars > saved profile. - # - DREADNODE_SERVER + DREADNODE_API_KEY (classic platform env) - # - DREADNODE_LLM_BASE + DREADNODE_LLM_API_KEY (runtime LLM proxy env) - if env.get("DREADNODE_SERVER") and env.get("DREADNODE_API_KEY"): - return env - if env.get("DREADNODE_LLM_BASE") and env.get("DREADNODE_LLM_API_KEY"): - return env - - # Fall back to saved profile (TUI/CLI mode) - # Profile lives at ~/.dreadnode/config.yaml (YAML format) + # Read saved profile via the SDK's own config system. This respects + # ``dn login``, ``/workspace`` switches, and profile selection — no + # need to parse YAML manually. try: - from pathlib import Path as _Path - import yaml # type: ignore[import-untyped] - - config_path = _Path.home() / ".dreadnode" / "config.yaml" - if config_path.exists(): - config = yaml.safe_load(config_path.read_text()) - active = config.get("active") - servers = config.get("servers", {}) - if active and active in servers: - profile = servers[active] - env.setdefault("DREADNODE_SERVER", profile.get("url", "")) - env.setdefault("DREADNODE_API_KEY", profile.get("api_key", "")) - env.setdefault("DREADNODE_ORGANIZATION", profile.get("default_organization", "")) - env.setdefault("DREADNODE_WORKSPACE", profile.get("default_workspace", "")) - env.setdefault("DREADNODE_PROJECT", profile.get("default_project", "")) + from dreadnode.app.config import UserConfig + + config = UserConfig.read() + profile_data = config.active_profile + if profile_data: + _, profile = profile_data + if profile.url: + env.setdefault("DREADNODE_SERVER", profile.url) + if profile.api_key: + env.setdefault("DREADNODE_API_KEY", profile.api_key) + if profile.organization: + env.setdefault("DREADNODE_ORGANIZATION", profile.organization) + if profile.workspace: + env.setdefault("DREADNODE_WORKSPACE", profile.workspace) + if profile.project: + env.setdefault("DREADNODE_PROJECT", profile.project) except Exception: - pass # Best-effort — will fail later in the script with a clear error + pass # Best-effort — dn.configure() in the script will try again return env diff --git a/capabilities/ai-red-teaming/tools/workflows.py b/capabilities/ai-red-teaming/tools/workflows.py index d8bdebf..af54747 100644 --- a/capabilities/ai-red-teaming/tools/workflows.py +++ b/capabilities/ai-red-teaming/tools/workflows.py @@ -31,26 +31,26 @@ def _resolve_platform_env() -> dict[str, str]: Mirrors attack_runner._resolve_platform_env so manually-executed workflows get the same credential resolution as auto-executed ones. + Always reads the saved profile to fill org/workspace/project scope. """ env = os.environ.copy() - if env.get("DREADNODE_SERVER") and env.get("DREADNODE_API_KEY"): - return env - if env.get("DREADNODE_LLM_BASE") and env.get("DREADNODE_LLM_API_KEY"): - return env try: - import yaml - config_path = Path.home() / ".dreadnode" / "config.yaml" - if config_path.exists(): - config = yaml.safe_load(config_path.read_text()) - active = config.get("active") - servers = config.get("servers", {}) - if active and active in servers: - profile = servers[active] - env.setdefault("DREADNODE_SERVER", profile.get("url", "")) - env.setdefault("DREADNODE_API_KEY", profile.get("api_key", "")) - env.setdefault("DREADNODE_ORGANIZATION", profile.get("default_organization", "")) - env.setdefault("DREADNODE_WORKSPACE", profile.get("default_workspace", "")) - env.setdefault("DREADNODE_PROJECT", profile.get("default_project", "")) + from dreadnode.app.config import UserConfig + + config = UserConfig.read() + profile_data = config.active_profile + if profile_data: + _, profile = profile_data + if profile.url: + env.setdefault("DREADNODE_SERVER", profile.url) + if profile.api_key: + env.setdefault("DREADNODE_API_KEY", profile.api_key) + if profile.organization: + env.setdefault("DREADNODE_ORGANIZATION", profile.organization) + if profile.workspace: + env.setdefault("DREADNODE_WORKSPACE", profile.workspace) + if profile.project: + env.setdefault("DREADNODE_PROJECT", profile.project) except Exception: pass return env