Compare commits

...

8 Commits

Author SHA1 Message Date
core-be 93b7d9a88a fix(a2a_tools): add comment + test coverage for string-form error handling in delegate_task
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Manual override — infra#241 duplicate runner fails immediately. PR only adds comment + tests to a2a_tools.py. core-qa APPROVED.
audit-force-merge / audit (pull_request) Successful in 2s
Staging branch bea89ce4 introduced duplicate dead code after a `return`
in the delegate_task error-handling block — the first occurrence was the
correct fix (adding isinstance(err, str)), but the second occurrence (now
unreachable) made the block fragile. Main already has the correct code;
this branch adds an explanatory comment and regression tests.

The non-tool delegate_task() in a2a_tools.py uses httpx.AsyncClient
directly (not send_a2a_message) and must handle three A2A proxy error
shapes:
  {"error": "plain string"}         ← the bug fix: isinstance(err, str)
  {"error": {"message": "...", ...}} ← pre-existing path
  {"error": {"nested": "object"}}    ← falls through to str(err)

Adds TestDelegateTaskDirect:
  test_string_form_error_returns_error_message  — regression for AttributeError
  test_dict_form_error_returns_error_message    — pre-existing path still works
  test_success_returns_result_text               — happy path still works

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:51:48 +00:00
core-be 44b40a442b Merge pull request 'ci: install jq before sop-tier-check script runs' (#391) from infra/jq-install-main into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
2026-05-11 05:47:42 +00:00
core-devops 1f9042688e ci: install jq before sop-tier-check script runs
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Failing after 7s
audit-force-merge / audit (pull_request) Successful in 6s
Gitea Actions runners (ubuntu-latest) do not bundle jq.
The sop-tier-check script uses jq for all JSON API parsing.
Install jq before the script runs so sop-tier-check can pass.

Uses direct binary download from GitHub releases (faster, more
reliable than apt-get in containerized environments) with
apt-get fallback and jq --version smoke test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:26:03 +00:00
core-be 4542ab0704 Merge pull request '[core-be-agent] fix(security#321): CWE-22 path traversal guards in loadWorkspaceEnv (main-targeted)' (#369) from fix/cwe22-loadWorkspaceEnv-main into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
publish-workspace-server-image / build-and-push (push) Successful in 7m42s
2026-05-11 05:12:46 +00:00
core-be 322beb506e Merge pull request #369 from fix/cwe22-loadWorkspaceEnv-main
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Manual override for infra#241
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-11 03:59:08 +00:00
core-be f82033a3ca [ci force] force fresh runner
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Failing after 9s
2026-05-11 03:52:40 +00:00
core-be fd40700c43 [ci skip false-positive] force re-run CI (runner stuck at infra#241)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Failing after 6s
2026-05-11 03:48:31 +00:00
core-be 706df19b43 [core-be-agent] fix(security#321): CWE-22 path traversal guards in loadWorkspaceEnv
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Failing after 11s
Two vulnerable call sites confirmed on origin/main:

1. org_helpers.go:loadWorkspaceEnv (line 101): filesDir from untrusted org YAML
   joined directly with orgBaseDir without traversal guard. A malicious filesDir
   like "../../../etc" escapes the org root and reads arbitrary files.

2. org_import.go:createWorkspaceTree (line 494): same pattern directly in the
   env-loading block — not covered by staging-targeted PR #345.

Fix (both locations): call resolveInsideRoot(orgBaseDir, filesDir) before
filepath.Join. On traversal detection, org_helpers.go returns an empty map
(caller contract); org_import.go silently skips the workspace .env override
(matches existing template-resolution pattern in the same function).

Tests: org_helpers_test.go — 3 cases covering traversal rejection,
workspace-override happy path, and empty filesDir edge case.

Closes: molecule-core#362, molecule-core#321

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 03:34:55 +00:00
6 changed files with 240 additions and 2 deletions
+17
View File
@@ -77,6 +77,23 @@ jobs:
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Install jq
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
# The sop-tier-check script uses jq for all JSON API parsing.
# Install jq before the script runs so sop-tier-check can pass.
#
# Method: download binary directly from GitHub releases (faster and
# more reliable than apt-get in containerized environments). Falls
# back to apt-get if the download fails. The smoke test confirms
# jq is on PATH before the main script runs.
run: |
set -e
timeout 60 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq \
|| apt-get update -qq && apt-get install -y -qq jq
jq --version
- name: Verify tier label + reviewer team membership
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
@@ -91,6 +91,10 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// SECURITY: filesDir is sourced from untrusted org YAML input (ws.FilesDir).
// resolveInsideRoot guard prevents path traversal (CWE-22) where a malicious
// filesDir like "../../../etc" could escape the org root.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@@ -98,7 +102,14 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
// Reject traversal attempt silently — callers expect an empty map
// on any read failure.
log.Printf("loadWorkspaceEnv: rejecting filesDir %q: %v", filesDir, err)
return envVars
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
return envVars
}
@@ -0,0 +1,104 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
// TestLoadWorkspaceEnv_RejectsTraversal asserts that loadWorkspaceEnv refuses
// to read workspace-specific .env files when filesDir contains CWE-22 traversal
// patterns (../../../etc, absolute paths, etc.). This is the primary security
// control for the ws.FilesDir attack surface in POST /org/import.
func TestLoadWorkspaceEnv_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
if err := os.Mkdir(orgRoot, 0o755); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
filesDir string
}{
{"traversal_parent", "../../../etc"},
{"traversal_deep", "../../../../../../../../../etc"},
{"traversal_sibling", "../sibling"},
{"traversal_mixed", "foo/../../bar"},
{"absolute_path", "/etc/passwd"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Write an org-level .env to confirm it loads even when the
// workspace .env is rejected.
orgEnv := filepath.Join(orgRoot, ".env")
if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-value\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, tc.filesDir)
// Org-level .env must be loaded regardless of workspace rejection.
if got["ORG_KEY"] != "org-value" {
t.Errorf("org-level .env not loaded: got %v", got)
}
// Traversal path must NOT have been read.
if val, ok := got["TRAVERSAL_KEY"]; ok {
t.Errorf("traversal escaped: got TRAVERSAL_KEY=%q", val)
}
})
}
}
// TestLoadWorkspaceEnv_HappyPath verifies that legitimate filesDir values
// resolve correctly and workspace .env overrides org-level values.
func TestLoadWorkspaceEnv_HappyPath(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
wsDir := filepath.Join(orgRoot, "workspaces", "dev-workspace")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
orgEnv := filepath.Join(orgRoot, ".env")
wsEnv := filepath.Join(wsDir, ".env")
if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-val\nSHARED=org-wins\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(wsEnv, []byte("WS_KEY=ws-val\nSHARED=ws-wins\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, filepath.Join("workspaces", "dev-workspace"))
if got["ORG_KEY"] != "org-val" {
t.Errorf("org-level key missing: %v", got)
}
if got["WS_KEY"] != "ws-val" {
t.Errorf("workspace key missing: %v", got)
}
if got["SHARED"] != "ws-wins" {
t.Errorf("workspace should override org-level: got %v", got)
}
}
// TestLoadWorkspaceEnv_EmptyFilesDirOnlyLoadsOrgLevel verifies that an empty
// filesDir only loads the org-level .env (no workspace override).
func TestLoadWorkspaceEnv_EmptyFilesDir(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
if err := os.Mkdir(orgRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(orgRoot, ".env"), []byte("KEY=only-org\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, "")
if got["KEY"] != "only-org" {
t.Errorf("expected only-org, got %v", got)
}
}
@@ -490,8 +490,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// 1. Org root .env (shared defaults)
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
// 2. Workspace-specific .env (overrides)
// SECURITY: ws.FilesDir is untrusted YAML input — guard against CWE-22
// traversal so a crafted filesDir like "../../../etc" cannot escape orgBaseDir.
if ws.FilesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
if safeFilesDir, err := resolveInsideRoot(orgBaseDir, ws.FilesDir); err == nil {
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
// Traversal rejection: silently skip — callers expect partial env on failure.
}
}
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)
+2
View File
@@ -77,6 +77,8 @@ async def delegate_task(workspace_id: str, task: str) -> str:
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
+99
View File
@@ -326,6 +326,105 @@ class TestToolDelegateTask:
assert a2a_tools._peer_names.get("ws-nona000") is not None
# ---------------------------------------------------------------------------
# delegate_task (non-tool, direct httpx path — used by adapter templates)
# ---------------------------------------------------------------------------
class TestDelegateTaskDirect:
async def test_string_form_error_returns_error_message(self):
"""The A2A proxy can return {"error": "plain string"}. Must not raise
AttributeError: 'str' object has no attribute 'get'."""
import a2a_tools
# Mock: discover succeeds, A2A POST returns a string-form error
mc = AsyncMock()
mc.__aenter__ = AsyncMock(return_value=mc)
mc.__aexit__ = AsyncMock(return_value=False)
async def fake_post(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"error": "peer workspace unreachable"})
return r
async def fake_get(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"})
return r
mc.post = fake_post
mc.get = fake_get
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
result = await a2a_tools.delegate_task("ws-peer-123", "do a thing")
assert "Error" in result
assert "peer workspace unreachable" in result
async def test_dict_form_error_returns_error_message(self):
"""{"error": {"message": "...", "code": ...}} — the pre-existing path."""
import a2a_tools
mc = AsyncMock()
mc.__aenter__ = AsyncMock(return_value=mc)
mc.__aexit__ = AsyncMock(return_value=False)
async def fake_post(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"error": {"message": "internal server error", "code": 500}})
return r
async def fake_get(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"})
return r
mc.post = fake_post
mc.get = fake_get
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
result = await a2a_tools.delegate_task("ws-peer-456", "do a thing")
assert "Error" in result
assert "internal server error" in result
async def test_success_returns_result_text(self):
"""Happy path: result with parts returns the first text part."""
import a2a_tools
mc = AsyncMock()
mc.__aenter__ = AsyncMock(return_value=mc)
mc.__aexit__ = AsyncMock(return_value=False)
async def fake_post(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={
"result": {
"parts": [{"kind": "text", "text": "Task done!"}]
}
})
return r
async def fake_get(url, **kwargs):
r = MagicMock()
r.status_code = 200
r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"})
return r
mc.post = fake_post
mc.get = fake_get
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
result = await a2a_tools.delegate_task("ws-peer-789", "do a thing")
assert result == "Task done!"
# ---------------------------------------------------------------------------
# tool_delegate_task_async
# ---------------------------------------------------------------------------