Skip to content

v0.4.3

Choose a tag to compare

@jin-bo jin-bo released this 05 May 02:20
· 88 commits to main since this release

Agentao 0.4.3

A permission-hardening + LLM-resilience release on top of 0.4.2.
No breaking changes; no public API or wire-format change.
pip install -U agentao upgrades in place from any 0.4.x release.

The headline features:

  • A hardline shell-safety floor that denies disk-wipe-class
    operations before any rule is consulted — including full-access.
    Embedded hosts that take policy responsibility themselves can opt
    out per construction.
  • An explicit LLM HTTP retry policy that replaces the OpenAI
    SDK's built-in retry. Honors Retry-After, bounds total sleep,
    is cancellable, and is streaming-aware — it never replays text
    that has already reached the user.
  • A tightened config trust boundary: project-scope
    permissions.json is no longer loaded; project-scope mcp.json is
    add-only; MCP destructiveHint adds friction even when the same
    server claims readOnlyHint.
  • A SearchTextTool skip-list + ripgrep fallback that kills
    the "stuck on node_modules" failure mode and rescues Windows
    boxes with rg.exe but no git.

Plus a mask_secret redact helper, Windows UTF-8 console
enforcement at import, a workspace-write sensitive-write preset,
and a contextvars-propagation fix for parallel tool batches.

Why this release

Three converging threads landed in the 2026-05-03 → 2026-05-04
window:

  1. Permission-hardening plan rev 3 — closed all five PRs
    (docs/design/permission-hardening-plan.md). The hardline floor
    (PR 2), Windows UTF-8 (PR 3), mask_secret (PR 4), and the
    shell-rc sensitive-write preset (PR 5) are user-visible additions
    from that plan. PR 1 (correctness fixes — isinstance JSON guard,
    MCP error classification, contextvars propagation) is also
    included.
  2. LLM resilience review — replaced the SDK's built-in retry
    with an Agentao-controlled policy after observing
    Retry-After-strands-the-caller and "upstream"-substring-bypass
    failure modes in the wild.
  3. Config trust boundary tightening (#25) — three small but
    load-bearing escalation paths (project permissions.json, project
    mcp.json overrides, untrusted MCP annotation consumption) closed
    in one PR.

Permission hardening

Hardline floor (opt-out)

agentao/permissions_hardline.py now runs as a pre-check inside
PermissionEngine.decide_detail(). The matched patterns:

  • rm -rf against /, system roots (/etc /usr /var /boot /bin /sbin /lib /home /root), or ~ / $HOME
  • mkfs[.*]
  • dd … of=/dev/(sd|nvme|hd|mmcblk|vd|xvd)… and > /dev/(sd|nvme|…)
  • Fork bomb :(){ :|:& };:
  • kill -1, kill -9 -1
  • shutdown / reboot / halt / poweroff at command position
  • init [06], telinit [06]
  • systemctl (poweroff|reboot|halt|kexec)

Each pattern handles sudo/env wrappers (with flags and value
args), quoted paths ("$HOME", '/etc/passwd'), split rm flags
(-r -f, --recursive --force), path-qualified rm (/bin/rm),
;/&&/|| + newline separators, command substitution, and
$HOME word boundaries.

The floor is default ON. Embedded hosts that sandbox Agentao
in a container or run their own deny pipeline can opt out:

PermissionEngine(
    project_root=...,
    user_root=...,
    enable_hardline=False,
)

full-access then means literal full access. The dual contract is
locked by tests/test_permissions.py::test_full_access_default_blocks_hardline_commands
and ::test_full_access_with_hardline_off_honors_literal_contract.

Decision reasons carry source prefixes

PermissionDecisionDetail.reason (and the host-facing
PermissionDecisionEvent.reason) now uses a stable taxonomy:

  • hardline:<description> — opt-out floor refused
  • mode-preset:<rule_id> — preset rule matched
  • user-rule:<rule_id> — user JSON rule matched

Hosts can render audit displays without parsing free-form text.
User-initiated denials are not in this taxonomy; they still
surface on ToolLifecycleEvent(cancelled).

Workspace-write sensitive-write preset

A new mode-scoped preset rule flags shell writes to ~/.bashrc,
~/.zshrc, ~/.profile, ~/.bash_profile, ~/.zprofile,
~/.netrc, ~/.pgpass, ~/.npmrc, ~/.pypirc via redirection
(>, >>, FD-prefixed redirects), tee, cp/mv, or sed -i.

The rule emits ASK, not DENY — installers (Homebrew, pyenv,
rustup, nvm) and devops scripts legitimately edit these files, so
the operator gets a prompt rather than a wall. full-access does
not carry the rule (preserving its literal-full-access contract).

Known coverage gap, deferred to a bashlex follow-up: variable
indirection (dst=~/.bashrc; echo X > "$dst"),
process-substitution wrappers, and literal expanded paths that
don't use ~ or $HOME.

LLM HTTP retry policy

The OpenAI SDK's built-in retry is now disabled (max_retries=0)
and replaced with a policy LLMClient controls end-to-end:

Aspect Behavior
Retryable 429 + 5xx + connection errors
Non-retryable 4xx other than 429
Backoff Retry-After if present (seconds or HTTP-date), capped at MAX_BACKOFF_SECONDS; otherwise jittered exponential
Wall-clock budget MAX_TOTAL_RETRY_SECONDS so a long Retry-After cannot strand the caller
Cancellation Sleeps run through _interruptible_sleep; a cancelled token aborts the loop immediately
Streaming chat_stream retries only before any chunk has reached on_text_chunk

The streaming-aware boundary is the key invariant: once any text
has been delivered to the host, mid-stream errors propagate
unchanged. Retrying would replay text the user already saw.

Raised exceptions carry .streamed so hosts (and the
LLM_CALL_COMPLETED error event in runtime/llm_call.py) can
choose between regenerate-from-scratch and resume-style retry
without counting LLM_TEXT events themselves.

The historical "upstream" substring matching 502 bodies and
silently falling back to non-streaming (bypassing the retry
policy) is fixed.

MCP error classification

agentao/mcp/client.py now exposes:

from agentao.mcp.client import classify_mcp_error, McpErrorKind
# McpErrorKind is one of: AUTH, SESSION_EXPIRED, TRANSPORT_DROPPED, OTHER

The call_tool retry path is now bucketed:

Bucket Behavior
AUTH (401/403/unauthorized/forbidden) Surface immediately; no retry. Retrying with the same creds just produces another 401/403 plus a reconnect storm.
SESSION_EXPIRED Reconnect, retry once.
TRANSPORT_DROPPED (anyio resource errors, httpx remote-disconnect, stringified pipe/connection close) Reconnect, retry once.
OTHER Surface without reconnecting.

Order matters: AUTH wins over session/transport so a server
stuffing multiple signals into one message
("401 Unauthorized: session expired") doesn't trigger a retry
storm. connection refused is intentionally not in
TRANSPORT_DROPPED — the server isn't listening at all and a
reconnect would fail identically.

The live transport is now disconnected before reconnecting so the
old subprocess / SSE stream doesn't leak for the manager's
lifetime.

Config trust boundary (#25)

Three small but load-bearing escalation paths closed:

  1. PermissionEngine no longer loads project-scope
    .agentao/permissions.json.
    A checked-in
    {"tool": "*", "action": "allow"} would have defeated the user
    policy because the engine returns on the first matching rule.
    Only <user_root>/permissions.json is honored; a stray project
    file is logged once and ignored. Permissions are a user/host
    concern, not a cwd concern — same model OS perms / IDE
    workspace-trust use.

  2. Project .agentao/mcp.json is add-only. It may declare new
    server names but cannot override a user-scope entry with the same
    key. Collisions warn and skip. Prevents a checked-in mcp.json
    from silently redirecting a known name (e.g. "github") to a
    different transport.

  3. McpTool consults readOnlyHint / destructiveHint only on
    trusted servers, and only to add friction.
    Per the MCP spec we
    never make tool-use decisions from annotations on untrusted
    servers. When both are set on the same trusted server,
    destructiveHint wins (security-positive bias — a Codex review
    during #25's /simplify pass caught the gap). New
    McpTool.mcp_annotations property for host introspection.

Other improvements

  • SearchTextTool skip-list + ripgrep fallback — default-skips
    .git, node_modules, .venv/venv, __pycache__, .tox,
    dist, build, target, .next/.nuxt, and the usual language
    caches. Fallback chain becomes git greprg → Python so
    non-git trees and Windows boxes with rg.exe but no git are
    rescued. Cross-platform via shutil.which; no IS_WINDOWS
    branching. Caller opts back in by naming a skip-dir in directory
    or file_pattern.

  • /replay delete <id> and /replay delete all — mirrors
    /sessions delete. Removes specific replay files by id-prefix or
    wipes all of them (single-key confirmation). The active
    recorder's file is skipped/refused so an in-flight write isn't
    orphaned.

  • agentao.redact.mask_secret — canonical helper for hiding
    credentials in logs, audit events, and host UIs. Default shape
    sk-A...3xZk for long values, ******** (same length) for
    short values, (not set) for missing. No internal callers
    migrated in this release; replaces the next ad-hoc truncation
    site that needs it.

  • Windows UTF-8 console enforcement at importagentao/__init__.py
    now calls _ensure_utf8() on package import. On Windows it sets
    PYTHONIOENCODING=utf-8 (only if unset), switches the console
    code page to CP_UTF8 (kernel32.SetConsoleOutputCP/SetConsoleCP),
    and reconfigures sys.stdin/stdout/stderr with encoding=utf-8
    and a lenient error handler. POSIX is a single-instruction no-op.
    Embedded hosts that import Agentao from a Windows console no
    longer hit UnicodeEncodeError on CJK file paths, curly quotes,
    or model-output emoji.

  • ToolExecutor parallel batches now propagate contextvars
    ThreadPoolExecutor workers don't inherit the parent thread's
    context, so contextvars.ContextVar state (host turn IDs, request
    metadata, structured-logging scopes) was lost when tools ran in
    parallel. contextvars.copy_context() is now captured on the
    parent thread per submission and dispatched through
    Context.run() — calling copy_context() inside the worker would
    copy the worker's empty context. Each plan gets a fresh context.

Documentation

  • Developer guide §7.6 — WeChat bot blueprint (mirrored to
    examples/wechat-bot/): long-polling daemon, contact-scoped
    permission preset, contrast table separating personal-account
    from Official Account / Enterprise WeChat webhook track.
  • Host contract clarifications: EventStream.add_observer
    fan-out documented; new architecture/embedding-vs-acp.{md,zh.md}
    decision tree; internal Transport / AgentEvent channel
    documented as a peer surface; new PUBLIC_EVENT_PROMOTION_PLAN.md
    staging MCPLifecycleEvent and LLMCallEvent promotion.
  • examples/protocol-injection/ — runnable end-to-end sample
    replacing every host→Agentao injection slot with a small adapter.
  • examples/personas/ — new home for prompt-configuration
    samples; seeded with daily-driver (evidence-first, citation-
    format table) and kawaii-buddy (情绪价值小助手 with mascot
    board).
  • examples/skills/ — host-agnostic skill gallery (#26)
    with zootopia-ppt, pro-ppt, ocr and bilingual README.
    Co-located skills in data-workbench, ticket-automation,
    batch-scheduler now carry callouts pointing back to the gallery.
  • docs/design/permission-hardening-plan.{md,zh.md} rev 3
    marked PRs 1–5 all landed; §10 reframed as post-ship follow-ups
    with only the bashlex supersedence of PR 5's regex tier
    remaining open.
  • docs/design/pi-mono-borrow-review.{md,zh.md} — decision
    record from surveying ~590 commits in pi-mono between v0.66 and
    v0.73, with keep / cut / reframe verdicts for each candidate.

What did not change

  • No public API or wire-format change. agentao.host
    Pydantic models, the host.events.v1.json / host.acp.v1.json
    schemas, and the Agentao(...) constructor signature are
    byte-for-byte unchanged from 0.4.2.
  • No required code change to upgrade. pip install -U agentao
    is the only step.
  • No CLI command rename. /replay delete is additive; existing
    /replay show, /sessions delete, etc. are unchanged.
  • The agentao.harness deprecated alias is still alive. Its
    removal stays scheduled for 0.5.0.

Tests

2466 passed, 4 skipped, 9 deselected under
AGENTAO_TEST_LIVE_MODELS=0 AGENTAO_TEST_LIVE_LLM=0 (CI's offline
mode). The strict typing gate (mypy --strict --package agentao.host) and the schema drift gate
(scripts/write_host_schema.py --check,
scripts/write_replay_schema.py --check) both green.

Upgrade

pip install -U agentao

If you embed Agentao with a custom permission engine and want the
hardline floor disabled (because you sandbox in a container or run
your own deny pipeline), pass enable_hardline=False:

from agentao.permissions import PermissionEngine

engine = PermissionEngine(
    project_root=...,
    user_root=...,
    enable_hardline=False,
)

If you previously kept a project-scope .agentao/permissions.json,
move its rules into your user-scope file
(<user_root>/permissions.json) — the project file is now ignored
with a one-time warning.

Out of scope (deferred)

  • bashlex-based supersedence of PR 5's regex tier. Variable
    indirection, process-substitution wrappers, and literal expanded
    paths in the workspace-write sensitive-write preset will be
    closed by a parser-based pass (mirroring the hardline scanner's
    approach). Not blocking — workspace-write already ASKs on
    everything not on the read-only allowlist.
  • HOME resolution under sudo. The hardline patterns match
    ~ / $HOME / ${HOME} syntactically. Reopen if a real bug surfaces.
  • agentao.harness alias removal — still scheduled for 0.5.0.
  • docs/releases/v0.4.0.md and v0.4.1.md — backfilling these
    remains deferred (carried over from v0.4.2.md).