diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index 0138717785..5160637105 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -84,6 +84,15 @@ graph TD
### 问答中的等待与失败
+前端与飞书的 Chat 回答保留项目内文件的相对位置,例如 `./notes/report.md`,便于核对产物;机器目录、项目外路径及另外声明为私有的目录仍隐藏。项目根目录本身显示为 `[project]`。本机 Markdown 链接保留可读标题,不发送无法在飞书使用的本地链接;状态、提议和权限门禁仍使用完整路径脱敏。这仅改变展现,不授予读取或写入权限。
+
+Frontend and Lark Chat answers retain project-relative filenames such as
+`./notes/report.md`, while hiding machine roots, other local paths and separately
+protected directories. The project root itself remains `[project]`. Local
+Markdown links keep their readable labels instead of unusable local destinations;
+status, proposals and gates still redact entire local paths. This changes answer
+presentation, not read or write authority.
+
管家与 Goal 的前端对话共用运行视图,适用于查询、编码、投研等各种任务:回答下方显示当前正在做的一步,例如「正在读取 notes.md」。使用 Codex 执行器时,展开「执行过程」可逐步查看思考、读取、搜索、运行的命令、调用的工具和修改的文件;同一步的开始与结束合并为一行,失败的步骤标出退出码,点击一行可查看完整命令、改动文件列表或思考内容。思考内容仅在模型向宿主公开时显示(有的模型只给出思考用时);不显示命令输出、工具参数与结果或文件改动内容,项目内路径显示为相对路径,其余本机路径和疑似凭据会被隐藏。「完成」只表示该步结束,不代表检查通过。这些步骤只保存在本机、仅所有者可读的会话回放记录中;回合结束超过 24 小时后,下次启动 LoopX Chat 时清理。其他执行器继续显示「最近活动」中的最近六条记录。尚未收到活动时保留等待提示,不根据等待时长推测执行进度。

diff --git a/loopx/chat.py b/loopx/chat.py
index 0ad2d8a69c..554e116b7f 100644
--- a/loopx/chat.py
+++ b/loopx/chat.py
@@ -142,16 +142,45 @@ def _stable_digest(payload: dict[str, Any], *, length: int = 24) -> str:
return hashlib.sha256(stable.encode("utf-8")).hexdigest()[:length]
-def redact_local_paths(text: str, *, protected_paths: Iterable[Path | str] = ()) -> str:
+def redact_local_paths(
+ text: str,
+ *,
+ protected_paths: Iterable[Path | str] = (),
+ project_relative: bool = False,
+) -> str:
+ """Hide local roots; visible answers may retain a project-relative filename.
+
+ Structured status, proposals and gates keep the default full redaction.
+ Additional protected roots always hide their descendants, even when nested
+ inside the project. This is text presentation, not filesystem admission.
+ """
redacted = str(text or "")
replacements = _protected_path_replacements(protected_paths)
+ def path_parts(value: str) -> tuple[str, ...]:
+ decoded = unquote(value).replace("\\", "/")
+ if re.match(r"^[A-Za-z]:/", decoded):
+ decoded = decoded.casefold()
+ return tuple(part for part in decoded.split("/") if part not in {"", "."})
+
+ private_roots = [path_parts(raw) for raw, label in replacements if label == "[local-path]"]
+
def replace_absolute_path(match: re.Match[str]) -> str:
matched = match.group(0)
candidate = matched.rstrip(".,;:!?)]}")
suffix = matched[len(candidate) :]
for raw, label in replacements:
if candidate == raw or candidate.startswith(f"{raw}/") or candidate.startswith(f"{raw}\\"):
+ if project_relative and label == "[project]" and candidate != raw:
+ relative = re.sub(r"\\+", "/", candidate[len(raw):]).lstrip("/")
+ components = unquote(relative).replace("\\", "/").split("/")
+ if ".." not in components:
+ # Equivalent spellings must not bypass a nested private
+ # root when opting into project-relative presentation.
+ candidate_parts = path_parts(candidate)
+ if any(candidate_parts[:len(root)] == root for root in private_roots):
+ return f"[local-path]{suffix}"
+ return f"./{relative}{suffix}"
return f"{label}{suffix}"
return f"[local-path]{suffix}"
@@ -233,7 +262,7 @@ def redact_response_markdown(text: str, *, protected_paths: Iterable[Path | str]
cursor = end
chunks.append(line[cursor:])
lines.append("".join(chunks))
- return redact_local_paths("".join(lines), protected_paths=protected)
+ return redact_local_paths("".join(lines), protected_paths=protected, project_relative=True)
class VisibleResponseStreamFilter:
@@ -298,7 +327,7 @@ def _accept_visible(self, text: str, *, final: bool) -> str:
if final:
ready = self.visible_pending
self.visible_pending = ""
- return redact_local_paths(ready, protected_paths=self.protected_paths)
+ return redact_local_paths(ready, protected_paths=self.protected_paths, project_relative=True)
# One chunk can hold several safe boundaries. Keep cutting until none
# is left, so an early sentence never holds back a long tail that the
# length fallback would otherwise release.
@@ -309,7 +338,7 @@ def _accept_visible(self, text: str, *, final: bool) -> str:
return ""
ready = self.visible_pending[:ready_length]
self.visible_pending = self.visible_pending[ready_length:]
- return redact_local_paths(ready, protected_paths=self.protected_paths)
+ return redact_local_paths(ready, protected_paths=self.protected_paths, project_relative=True)
def feed(self, chunk: str) -> str:
if self.envelope_started:
diff --git a/tests/test_chat_path_redaction.py b/tests/test_chat_path_redaction.py
index caf5896197..9ca02819b1 100644
--- a/tests/test_chat_path_redaction.py
+++ b/tests/test_chat_path_redaction.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread
@@ -9,6 +10,7 @@
import pytest
from loopx.chat import VisibleResponseStreamFilter, parse_agent_response, redact_local_paths, redact_response_markdown
+from loopx.chat_acp import ACPStdioAdapter
from loopx.chat_status_api import ChatStatusRequestMixin
@@ -51,13 +53,129 @@ def test_response_link_repair_preserves_code_and_does_not_rewrite_status_json():
link = "[label](/custom-volume/project/report.md)"
code = f"`{link}`\n```md\n{link}\n```\n"
assert redact_response_markdown(code, protected_paths=["/custom-volume/project"]) == code.replace(
- "/custom-volume/project/report.md", "[project]")
+ "/custom-volume/project/report.md", "./report.md")
assert json.loads(redact_local_paths(json.dumps({"message": link}), protected_paths=["/custom-volume/project"])) == {
"message": "[label]([project])"}
raw = '' + json.dumps({"message": link}) + ''
assert parse_agent_response(raw, protected_paths=["/custom-volume/project"])["message"] == "label"
+@pytest.mark.parametrize("root,child", [
+ ("/custom-volume/project", "/notes/report.md:12"),
+ ("/custom-volume/project space", "/notes/report.md"),
+ (r"Q:\project", r"\notes\report.md"),
+])
+def test_response_and_split_stream_retain_the_project_filename(root, child):
+ text = f"Read back `{root}{child}`.\n"
+ expected = "Read back `./" + child.lstrip("/\\").replace("\\", "/") + "`.\n"
+ assert redact_response_markdown(text, protected_paths=[root]) == expected
+ assert parse_agent_response(text, protected_paths=[root])["message"] == expected.strip()
+ for split in range(len(text) + 1):
+ stream = VisibleResponseStreamFilter(protected_paths=[root])
+ assert stream.feed(text[:split]) + stream.feed(text[split:]) + stream.finish() == expected
+
+
+def test_answer_path_presentation_does_not_relax_nested_private_roots_or_structured_fields():
+ root = "/custom-volume/project"
+ secret_root = root + "/runtime"
+ paths = [root, secret_root]
+ text = f"`{root}/notes/report.md`; `{secret_root}/private/gate.json`; `/home/other/private.txt`."
+ assert parse_agent_response(text, protected_paths=paths)["message"] == (
+ "`./notes/report.md`; `[local-path]`; `[local-path]`."
+ )
+ raw = '' + json.dumps({
+ "message": text,
+ "proposals": [{"kind": "todo", "text": f"Read {root}/notes/report.md", "rationale": f"See {secret_root}/private/gate.json"}],
+ }) + ''
+ parsed = parse_agent_response(raw, protected_paths=paths)
+ assert parsed["message"].startswith("`./notes/report.md`")
+ assert parsed["proposals"][0]["text"] == "Read [project]"
+ assert parsed["proposals"][0]["rationale"] == "See [local-path]"
+
+
+@pytest.mark.parametrize("private_path", [
+ "/custom-volume/project/./runtime/private/gate.json",
+ "/custom-volume/project//runtime/private/gate.json",
+ "/custom-volume/project/runtime/./private/gate.json",
+ "/custom-volume/project/%2e/runtime/private/gate.json",
+ "/custom-volume/project/runtime%2fprivate/gate.json",
+ r"Q:\project\.\runtime\private\gate.json",
+ r"Q:\project\\runtime\private\gate.json",
+ r"Q:\project\.\RUNTIME\private\gate.json",
+])
+def test_answer_and_every_stream_split_hide_equivalent_nested_private_paths(private_path):
+ paths = [r"Q:\project", r"Q:\project\runtime"] if private_path.startswith("Q:") else [
+ "/custom-volume/project", "/custom-volume/project/runtime",
+ ]
+ text = f"Read back `{private_path}`.\n"
+ expected = "Read back `[local-path]`.\n"
+ assert redact_response_markdown(text, protected_paths=paths) == expected
+ assert parse_agent_response(text, protected_paths=paths)["message"] == expected.strip()
+ for split in range(len(text) + 1):
+ stream = VisibleResponseStreamFilter(protected_paths=paths)
+ assert stream.feed(text[:split]) + stream.feed(text[split:]) + stream.finish() == expected
+
+
+def test_acp_stdio_final_and_stream_hide_private_aliases_and_keep_public_filename(tmp_path):
+ project = tmp_path / "project"
+ private = project / "runtime"
+ private.mkdir(parents=True)
+ secret = private / "synthetic-secret.md"
+ secret.write_text("synthetic fixture")
+ aliases = [str(secret), f"{project}/./runtime/{secret.name}", f"{project}//runtime/{secret.name}"]
+ assert all(Path(alias).resolve() == secret.resolve() for alias in aliases)
+ text = "\n".join([*aliases, str(project / "notes/report.md")]) + "\n"
+ expected = "[local-path]\n" * len(aliases) + "./notes/report.md\n"
+ provider = tmp_path / "synthetic_acp.py"
+ provider.write_text('''import json, sys
+for line in sys.stdin:
+ request = json.loads(line)
+ method = request.get("method")
+ if method == "initialize":
+ result = {"protocolVersion": 1, "agentCapabilities": {}}
+ elif method == "session/new":
+ result = {"sessionId": "fixture"}
+ elif method == "session/prompt":
+ for chunk in sys.argv[1]:
+ print(json.dumps({"jsonrpc": "2.0", "method": "session/update",
+ "params": {"sessionId": "fixture", "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "content": {"type": "text", "text": chunk}}}}), flush=True)
+ result = {"stopReason": "end_turn"}
+ else:
+ continue
+ print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True)
+''')
+ events = []
+ adapter = ACPStdioAdapter.start(
+ command=(sys.executable, str(provider), text), work_dir=project, agent_work_dir=private,
+ startup_timeout_sec=5, idle_timeout_sec=5, hard_timeout_sec=10,
+ )
+ try:
+ response = adapter.start_turn("Report fixture locations", lambda kind, payload: events.append((kind, payload)))
+ finally:
+ adapter.close_session()
+ assert response["message"] == expected.strip()
+ assert "".join(payload["text"] for kind, payload in events if kind == "answer.delta") == expected
+ assert [payload["response"]["message"] for kind, payload in events if kind == "answer.final"] == [expected.strip()]
+
+
+@pytest.mark.parametrize("suffix", ["/../other/private.txt", "/notes/../../private.txt", "/%2e%2e/private.txt"])
+def test_answer_paths_do_not_present_traversal_as_project_files(suffix):
+ text = "/custom-volume/project" + suffix
+ assert parse_agent_response(text, protected_paths=["/custom-volume/project"])["message"] == "[project]"
+
+
+def test_stream_holds_a_long_project_filename_until_its_boundary():
+ root = "/custom-volume/" + "r" * 190
+ relative = "notes/" + "s" * 190 + "/report.md"
+ path = root + "/" + relative
+ stream = VisibleResponseStreamFilter(protected_paths=[root])
+ assert stream.feed(path[:170]) == ""
+ assert stream.feed(path[170:300]) == ""
+ assert stream.feed(path[300:] + "\n") + stream.finish() == "./" + relative + "\n"
+
+
@pytest.mark.parametrize("root", [
"/custom-volume/private-state", "/custom-volume/private state",
"/custom-volume/" + "r" * 190,