frontieragentAgent
§ · Nested detail page

工具权限、审批与沙箱契约 深度解析

这一页拆 FrontierAgent 如何给工具打 metadata、如何在运行前做风险判断、plan mode 如何拦截写操作,以及为什么 output_paths 是发布授权。

§ 00 · IMPORT CHAIN

导入链与契约

工具层的第一层契约是 ToolMeta。sources/frontieragent/plugins/tools/meta.py:15 定义 read-only、destructive、concurrency_safe、timeout 和 max_result_chars;sources/frontieragent/plugins/tools/meta.py:46 以后为 web、file、bash、python、finance 等工具分类。这个 metadata 决定调度和溢出策略,也给审批层提供风险基础。

src/plugins/tools/meta.py · L15-42Python
15@dataclass(frozen=True)
16class ToolMeta:
17    """Metadata for a tool instance.
18
19    Attributes:
20        is_read_only: Tool only reads data, never modifies state.
21        is_destructive: Tool can cause irreversible changes (file deletion, etc.).
22        concurrency_safe: Safe to run in parallel with other tools.
23        timeout: Maximum execution time in seconds.
24        category: Logical grouping (web, file, compute, search, vision).
25        max_result_chars: Max characters to keep inline. Overflow saved to disk.
26            0 means no limit (default for most tools).
27        result_is_ranked: The result is ordered by RELEVANCE, best first, so a
28            contiguous head is worth more than a head-plus-tail split — the tail
29            of a ranked list is its worst entries. False (the default) means the
30            result is sequential: a transcript, a document, a file listing, an
31            exec log, where the end carries the verdict and is worth keeping.
32            Read by ``_overflow`` when TOOL_RESULT_TRUNCATION=auto.
33    """
34
35    is_read_only: bool = True
36    is_destructive: bool = False
37    concurrency_safe: bool = True
38    timeout: int = 45
39    category: str = ""
40    max_result_chars: int = 0
41    result_is_ranked: bool = False
42
§ 01 · CORE IMPL

核心实现,逐段拆

▸风险评估后才让工具执行

TerminalObserver 在 tool call 前做本地路径重写、plan mode 拦截、风险评估、diff preview 和人工确认。sources/frontieragent/apodex/observers.py:431 把 cwd 内绝对路径改成相对路径,sources/frontieragent/apodex/observers.py:447 在 plan mode 下阻断 mutating tool,sources/frontieragent/apodex/observers.py:498 才向 approver 请求确认。

src/apodex/observers.py · L431-535Python
431        # Rewrite an absolute cwd-internal path to relative so file tools take
432        # the fast local path (abs paths hit a ~50s sandbox bootstrap). The
433        # rewritten args are what actually executes (via rewrite_args).
434        rewritten = localize_path_args(name, args, self.cwd)
435        eff_args = rewritten if rewritten is not None else args
436
437        # Plan mode: ``exit_plan_mode`` is a human-approval gate that unlocks
438        # edits; every other mutating call is blocked until then.
439        if name == "exit_plan_mode":
440            self._render_activity_call(name, eff_args, call_id=call_id)
441            intervention = await self._review_plan(
442                str(eff_args.get("plan", "")).strip(),
443            )
444            if intervention.skip_with_result is not None:
445                self._activity_synthetic[-1] = True
446            return intervention
447        if (self.plan_state is not None and self.plan_state.active
448                and is_mutating_tool(name, eff_args)):
449            self._render_tool_call(
450                name, eff_args, call_id=call_id, risk_reason="plan mode",
451            )
452            self.r.note(f"✗ plan mode: '{name}' is disabled until the plan is approved")
453            return self._skip_tool(
454                f"[Plan mode is active — '{name}' is disabled. Investigate with "
455                "read/search tools, then call exit_plan_mode(plan=...) to get the "
456                "user's approval before editing.]"
457            )
458
459        risk = assess_with_rules(
460            name, eff_args, self.cwd, self.rules,
461            auto_for_me=getattr(self.approver, "auto_for_me", False),
462        )
463        self._render_tool_call(
464            name, eff_args, call_id=call_id,
465            risk_reason=(risk.danger or ("" if risk.level == RISK_SAFE else risk.reason)),
466            danger=bool(risk.danger),
467        )
468
469        # Preview of the proposed action, shown *inside* the approval modal so
470        # the user can see exactly what they're approving (diff / command).
471        preview = ""
472        preview_kind = ""
473        # Read-before-edit: don't let the model overwrite/edit an existing file
474        # it hasn't read this session, or one changed on disk since it read it.
475        if name in _DIFF_TOOLS:
476            guard = fsguard.check_can_edit(
477                str(eff_args.get("path") or eff_args.get("file_path") or ""), self.cwd,
478            )
479            if guard:
480                self.r.note(f"✗ {name} blocked: {guard}")
481                return self._skip_tool(f"[{guard}]")
482            # Show exactly what will change on disk before asking for approval.
483            diff = unified_diff(name, eff_args, self.cwd)
484            if diff:
485                self.r.diff_preview(diff, stats=change_stats(name, eff_args, self.cwd))
486                preview, preview_kind = diff, "diff"
487        elif name == "bash":
488            cmd = str(eff_args.get("command", "")).strip()
489            if cmd:
490                preview, preview_kind = cmd, "command"
491
492        if risk.level == RISK_DENY:
493            self.r.note(f"✗ blocked: {risk.reason}")
494            return self._skip_tool(
495                f"[blocked by safety policy: {risk.reason}]",
496            )
497        if risk.level != RISK_SAFE:  # confirm: ask the human
498            decision = await self.approver.confirm(
499                name, risk.target, risk.reason, dangerous=risk.danger,
500                preview=preview, preview_kind=preview_kind,
501            )
502            if not decision.approved:
503                # Human-in-the-loop: a typed instruction redirects the agent;
504                # it's fed back as the (declined) call's result so the model
505                # adapts on the next turn instead of just retrying blindly.
506                if decision.feedback:
507                    self.r.note(f"↳ redirecting {name}: {decision.feedback}")
508                    return self._skip_tool(
509                        f"[The user declined to run this {name} call. "
510                        f"Follow their instruction instead: {decision.feedback}]"
511                    )
512                # Plain reject → stop the task and hand control back (Claude /
513                # kimi behaviour). Re-trying a rejected action just makes the
514                # model ramble; ``[e] redirect`` is how the user keeps it going.
515                self._abort_reason = "user_rejected"
516                self.r.note(f"✗ rejected {name} — stopping task (use [e] to redirect instead)")
517                return self._skip_tool(
518                    f"[user rejected this {name} call — task stopped]",
519                )
520            # 'Always allow this command' → persist a rule so identical calls
521            # auto-approve next time (danger/deny still override it).
522            if decision.remember and self.rules is not None:
523                self.r.note(f"✓ always allowing: {self.rules.add_allow(name, eff_args)}")
524
525        # Allowed (safe or approved) → snapshot a mutating op before it runs so
526        # the change is diffable + revertable, then let it execute.
527        if self.journal is not None and name in MUTATING_TOOLS:
528            p = eff_args.get("path") or eff_args.get("file_path")
529            if isinstance(p, str) and p:
530                self.journal.record_before(p)
531        elif self.journal is not None and is_mutating_tool(name, eff_args):
532            # Awaited before the call executes, so the baseline is always in
533            # place first — including for a sibling that reuses it.
534            await self._begin_journal_scan()
535        return ToolCallIntervention(rewrite_args=rewritten) if rewritten is not None else None

▸发布权限不是 boolean,而是 manifest

AgentTeamAssignmentSpec 把 output_paths 作为授权来源,这比 publish=true 更难误用。sources/frontieragent/plugins/tools/assign_task.py:167 禁止多余字段,sources/frontieragent/plugins/tools/assign_task.py:185 规定 exact absolute final file paths,sources/frontieragent/plugins/tools/assign_task.py:202 的 can_publish 从 path list 派生。

src/plugins/tools/assign_task.py · L167-220Python
167class AgentTeamAssignmentSpec(AssignmentSpec):
168    """One assignment. Carrying ``output_paths`` is what authorizes it to write
169    those ``/outputs`` paths; without them the task runs workspace-only."""
170
171    model_config = ConfigDict(extra="forbid")
172
173    publish: StrictBool | None = Field(
174        default=None,
175        # Marked deprecated in the advertised schema only. Pydantic's own
176        # ``deprecated=True`` warns on every attribute read, and
177        # ``validate_publish_contract`` below reads it twice per assignment --
178        # that is a DeprecationWarning per validated task in every run.
179        json_schema_extra={"deprecated": True},
180        description=(
181            "Deprecated, omit it. output_paths alone grants publication. If "
182            "sent it must agree with the manifest."
183        ),
184    )
185    output_paths: list[StrictStr] = Field(
186        default_factory=list,
187        description=(
188            "Exact absolute final file paths under /outputs. Supplying them IS "
189            "the publication grant, so set them on exactly one final-publisher "
190            "assignment and omit them on every other. Pass a JSON array even "
191            "for one path."
192        ),
193    )
194    replace_manifest: StrictBool = Field(
195        default=False,
196        description=(
197            "Set true only on a follow-up to the existing publisher when the "
198            "required final output formats genuinely changed."
199        ),
200    )
201
202    @property
203    def can_publish(self) -> bool:
204        """Whether this assignment may write ``/outputs``.
205
206        Derived from the manifest alone: a path list is the grant.
207        """
208        return bool(self.output_paths)
209
210    @field_validator("publish", mode="before")
211    @classmethod
212    def normalise_publish_boolean(cls, value: Any) -> Any:
213        """Accept common JSON boolean strings in the structured field."""
214        if isinstance(value, str):
215            normalised = value.strip().lower()
216            if normalised in {"false", "true"}:
217                return normalised == "true"
218        return value
219
220    @field_validator("output_paths", mode="before")
§ 02 · KEY FLOW

关键流程

tool call ├─ localize cwd-internal paths ├─ plan mode gate ├─ assess_with_rules ├─ diff / command preview ├─ user approval or deny └─ journal snapshot before mutation

▸允许之后才记录可变更快照

审批链路不是审计日志的替代品。只有动作被允许后,sources/frontieragent/apodex/observers.py:525 才会创建 journal snapshot;这能把“批准”和“真实写入前状态”绑定起来,而不是只保留一条聊天记录。

这个流程的要点是 fail-closed。sources/frontieragent/apodex/observers.py:492 对 RISK_DENY 直接 skip,sources/frontieragent/apodex/observers.py:502 对用户拒绝的动作停止或重定向,sources/frontieragent/apodex/observers.py:525 在允许后才给 mutating op 做 journal snapshot。

§ 03 · EDGE CASES

边界 / 失败模式

  • 未读文件不能直接编辑:sources/frontieragent/apodex/observers.py:473 到 sources/frontieragent/apodex/observers.py:481 调用 fsguard。
  • bash 命令被当作 preview_kind=command 展示:sources/frontieragent/apodex/observers.py:487。
  • 用户拒绝后不会盲目重试:sources/frontieragent/apodex/observers.py:512 到 sources/frontieragent/apodex/observers.py:519。
  • 工具 metadata 里 bash 标为 destructive 且 non-concurrent:sources/frontieragent/plugins/tools/meta.py:85。
Sub-page · 2026-08-26 Source · ApodexAI/FrontierAgent ↑ frontieragent 主页面