Skip to content

deepagents 0.6.12 parity + drop-in, security fixes, and a world-class CLI pass#160

Merged
draxios merged 28 commits into
mainfrom
chore/resiliency-hardening
Jul 12, 2026
Merged

deepagents 0.6.12 parity + drop-in, security fixes, and a world-class CLI pass#160
draxios merged 28 commits into
mainfrom
chore/resiliency-hardening

Conversation

@draxios

@draxios draxios commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch brings bog-agents to full parity with, and drop-in interchangeability for, deepagents 0.6.12 (the upstream fork), fixes several real security bugs found along the way, and makes the CLI world-class — including spec-compliant MCP OAuth. It also carries the earlier resiliency-hardening work this branch started from.

All gates green across every package: SDK 2540 passed · CLI 4988 passed · daemon 141 passed (0 failures), ruff + ruff-format + ty clean, lockfiles consistent.

deepagents 0.6.12 parity + drop-in

bog-agents is now a source-level drop-in for the deepagents public API and co-installable with it (the wcmatch<7.0 cap that made the two unresolvable in one venv is lifted). Verified: all 20 deepagents public symbols import from both bog_agents and bog_agents.deepagents, and create_deep_agent(system_prompt={"base": None, ...}) compiles instead of raising TypeError.

  • Backend rewrite (bog forked before deepagents 0.5.0): FileData v2 (content: str + encoding), Result dataclasses, delete() on every backend, write() overwrites (upstream contract), StateBackend()/StoreBackend(store=…) construct without a runtime, new LangSmithSandbox + ContextHubBackend. Fixes three real bugs (grep char-iteration, Windows glob, composite route-leak).
  • Core API: SystemPromptConfig, full export surface, unclamped recursion limit, user-middleware replace-in-place, GeneralPurposeSubagentProfile actually disables the task tool, per-subagent profile resolution, provider-aware model_matches_spec.
  • Built-in profiles (the registry shipped zero before): Anthropic Opus/Sonnet/Haiku, Codex, Nemotron (+ text-form tool-call parser), and the OpenRouter provider profile that carries the Azure-routing fix (OpenRouter + reasoning models were broken multi-turn without it). Plus Bedrock prompt caching and reachable video-frame read.

Security fixes (all pre-existing, found by audit)

  • Filesystem deny rules never filtered ls/glob/grep results — a pathless grep(pattern="API_KEY") exfiltrated denied paths and file contents. Now filtered; wired into create_agent and pinned end-to-end.
  • Symlinked-skill refusal only guarded the sync pathainvoke bypassed containment; both paths now share one chokepoint.
  • Approval dialog + MCP viewer rendered untrusted strings as unescaped Rich markup — injection into the human-trust surface, and a bare [/dim] crashed the HITL dialog. Escaped (escape-last ordering) + control-char sanitized.
  • Checkpointing was silently broken on fresh repos (modern git) and staged the user's real git index; now uses an isolated GIT_INDEX_FILE and only commits on an unborn HEAD.

World-class CLI + deepagents_code ports

  • Spec-compliant MCP OAuth via the official mcp SDK OAuthClientProvider (RFC 9728 discovery + Dynamic Client Registration + PKCE + refresh — not hand-rolled). /mcp login|logout|status, single-use 127.0.0.1 loopback callback, tokens stored 0600 and fail-closed, refresh-path provider binds no socket and never opens a browser. Replaces the dead hand-rolled oauth_mcp.py.
  • Native /effort mapping to each provider's real reasoning knob (was capping reasoning models at 1024 tokens), theme system (/theme + user themes, default palette proven byte-identical), skill trust store (opt-in, fingerprinted, fail-closed), /goal+/rubric, managed ripgrep, ${VAR} MCP headers, env-var registry + config manifest, ctrl+x external editor, doctor improvements.

Verification & CI notes

  • Every substantial change was landed via adversarial review (independent re-run of gates + test-integrity audit + security re-exploitation for the auth/permission work).
  • Windows-local note: symlink-swap trust tests and system-prompt snapshot tests skip on Windows (no symlink privilege / skipif(win32)); they run on Linux CI. The trust-store fingerprint/fail-closed and swap logic were additionally verified by hand.

See PARITY.md and libs/cli/CLI_AUDIT.md for the full audit trail and the deferred backlog.

draxios added 28 commits June 22, 2026 22:34
…eware and backends

Verified hardening sweep (all changes covered by new/extended unit tests;
ruff + format + ty clean; 1693 unit tests pass).

Correctness / reliability:
- checkpointing: read the singular `ToolCallRequest.tool_call` (langgraph
  1.2.x) instead of a non-existent `.tool_calls` list, which made the
  hasattr guard always false — checkpoints were never created, so /undo and
  /rewind had nothing to revert to.
- graph: de-duplicate feature-wired middleware (Skills/Memory/PromptCaching)
  against user-supplied `middleware=` and add a keep-first backstop so a
  feature passed via both a kwarg and an explicit instance no longer raises
  langchain's opaque "duplicate middleware instances" AssertionError.
- worktree: split spawn_parallel_tasks into sync/async tools and register a
  coroutine so the async agent path no longer crashes on
  `asyncio.ensure_future`; surface git failures from create_worktree instead
  of returning a WorktreeInfo for a directory that was never created.

Resiliency (fail-open where it should degrade, not crash):
- memory: a non-`file_not_found` backend error (permission/timeout/transient)
  now degrades that source with a warning instead of aborting the turn.
- offline_mode: stop leaking a process-global socket default timeout onto
  every later untimed socket; use per-socket timeouts with save/restore.
- multi_edit / read_many_files / filesystem / store / composite: isolate
  per-item failures (partial-success contract), bound output/limit/file size
  to avoid OOM/hangs, and log previously-swallowed state-sync errors.
- context_packing / intelligent_compaction: pack via `request.override(...)`
  instead of mutating the shared request in place, snap the keep boundary so
  a tool_use/tool_result pair is never split, and fail open on error.
- audit_trail: record model-call failures so a 5xx/timeout doesn't leave a
  dangling, unresolved llm_call in the compliance record.
- hybrid_search: error boundary around embedding-cache save.
- notifications: clipboard subprocess gets a timeout + context manager.

Stability (Windows non-en-US / encoding):
- repo_map / local_sandbox: read/write with encoding="utf-8" so the symbol
  index and seatbelt profile don't decode through cp1252/cp932/cp949.
- code_intelligence: prune vendored/build dirs and cap files scanned.

Docs:
- graph docstring no longer advertises a SafeToolsMiddleware class that does
  not exist (safe-tool auto-approval is data-driven via SafeToolsConfig).
…sessions, ops)

Verified hardening sweep (new/extended unit tests; ruff + format + ty clean;
4538 unit tests pass).

TUI responsiveness / correctness:
- app, widgets/chat_input: read the system clipboard on a background thread
  (paste + right-click paste) so a slow/hung helper can't freeze the event
  loop; sync fallback so a paste never crashes the app.
- app: hide the spinner on an empty status string (not just None) so slash
  commands stop leaving a blank "esc to interrupt" animation; drain the
  pending-message queue after inline /compact and /butcher so messages typed
  during them aren't perceived as dropped.

Auth / secrets / resiliency:
- bedrock_refresh: serialize the refresh critical section so concurrent
  expired-creds calls can't spawn multiple `aws sso login` subprocesses.
- vars_store: write plaintext vars with mode=0o600 before the atomic rename
  (no world-readable window) and leave a one-time breadcrumb when the keyring
  probe fails and secrets silently downgrade to TOML.
- remote_sandbox: add ssh ConnectTimeout + BatchMode and an overall
  asyncio.wait_for ceiling so a wedged ssh can't hang the CLI forever.
- server: atexit fallback to stop an orphaned langgraph dev subprocess.
- non_interactive: cancel-and-drain the MCP preload task on every early
  return so it can't leak coroutines/subprocesses.

Observability / robustness:
- main: handle malformed --pipeline YAML (and non-dict / bare-string steps)
  with a clear error instead of an uncaught traceback.
- causal/ledger: track and expose disk-append degradation instead of
  silently swallowing the OSError.
- compliance: add a `no_event_with_payload` collector so audit packs can
  assert on rule verdicts (e.g. no rule_fire with action=deny).
- jobs_manager: skip unreadable job files (OSError) instead of crashing
  PersistentJobsManager construction.

Stability (encoding / atomic writes):
- mentions, agent, teach, pipeline, butcher: encoding="utf-8" on user/model
  text I/O and subprocess decode.
- auto_memory, session_fork: switch to atomic_write_text so a crash
  mid-write can't corrupt memory/fork files.
- clipboard: timeout on the Windows clipboard subprocesses.
…, lint)

Verified hardening sweep (new tests in tests/test_daemon_hardening.py; ruff
clean with PLW1514 now enabled; 130 tests pass).

Reliability / security:
- api: reject a git-push webhook with a missing/empty `ref` instead of
  letting branch="" match `fnmatch("", "*")` and fire every wildcard
  GIT_PUSH job on a malformed payload.
- main: write the daemon auth token atomically with owner-only perms before
  the rename (no default-perm window, no truncated token on a crashed
  write), and regenerate an empty/blank token file instead of persisting a
  value that would reject every request.

Reliability / observability:
- scheduler: prune heavy dirs (.git/node_modules/.venv/build caches) in place
  and cap files examined per tick so a large watch_dir can't make one
  file-change tick overrun the interval and starve cron/interval jobs.
- runner: log a warning (with the skill path) when SKILL.md frontmatter is
  invalid YAML or not a mapping, instead of silently dropping `chain:`.

Tooling:
- pyproject: enable ruff PLW1514 (unspecified-encoding) via the preview +
  explicit-preview-rules gate, per the CLAUDE.md encoding mandate.
Implements reviewed propose-batch items P2/P3/P16/P22/P23/P25/P33. Each was a
feature that silently did nothing (fail-open) or an unbounded server path; now
they actually enforce/route, with negative tests. All opt-in (not in the
default stack); ruff+format+ty clean; 1755 SDK unit tests pass.

- smart_approvals (P2): SmartApprovalsMiddleware was a pure pass-through —
  evaluate()/record_human_decision() had zero callers, so every tool ran
  unimpeded. Add wrap_tool_call/awrap_tool_call that evaluate the call and
  FAIL CLOSED: a denied/escalated tool is never executed; a denial ToolMessage
  is returned instead.
- air_gapped (P3): the egress policy was a prompt suggestion only. Add a
  best-effort wrap_tool_call egress gate that intercepts known egress tools
  (web_fetch/fetch_url/http_request and networked shell commands), resolves
  the target host, consults AirGapStore.check_allowed, and denies (fail
  closed). Docstring now states the best-effort scope honestly.
- thinking (P22): _detect_provider used startswith("claude"), so Bedrock
  inference-profile ids (us.anthropic.claude-*, eu./apac.) classified as
  'unknown' and native thinking was silently disabled for ALL Bedrock Claude
  users. Match 'claude' as a substring; keep gemini/openai prefix-anchored.
- model_cascade (P23): awrap_model_call was a pass-through, so the advertised
  cost-aware routing never altered the model. Implement downshift-only routing
  (route() -> request.override(model=...)) with a sync hook; fall through on
  any uncertainty so a turn is never blocked.
- adaptive_context (P25): only an async pass-through existed; the tier config
  was dead. Wire sync+async wrap_model_call to truncate oversized tool outputs
  (override-not-mutate, message count/order preserved) and log the summarize
  threshold; fail open.
- serve (P16/P33): _check_api_key now uses secrets.compare_digest
  (constant-time); per-request ainvoke/astream are bounded by
  request_timeout (504 on timeout) and gated by a lazily-created
  Semaphore(max_concurrent_requests); the _threads map is an LRU OrderedDict
  bounded by a new ServerConfig.max_tracked_threads (no unbounded growth).
…dreamscape laws

Implements reviewed propose-batch items P4/P5/P24/P1. ruff+format+ty clean;
CLI suite 4587 pass; dreamscape opt-in/no-op-when-disabled invariant preserved.

Security:
- SSRF (P4/P5): /web fetch_url, the agent fetch_url tool, and http_request had
  no SSRF guard — loopback/link-local/private IPs (incl. 169.254.169.254 cloud
  metadata) were reachable and post-redirect URLs were never re-validated.
  Add a shared assert_fetch_allowed guard (http/https only; reject any address
  that resolves to private/loopback/link-local/reserved/multicast/unspecified,
  unwrapping IPv4-mapped/6to4 IPv6 to stop tunneling) and follow redirects
  manually, re-validating every hop. Fail closed. Kept the existing unicode
  gate (check_url_safety) separate and unchanged. (Residual: urllib/requests
  re-resolve DNS at connect time, so a determined DNS-rebinding attack is not
  fully closed — common SSRF vectors are.)
- TraceFile signature (P24): the Ed25519 signature covered only merkle_root +
  session_id, so producer/actor/notes could be forged on a file that still
  rendered as "verified". Bind a canonical header digest into the signed
  message under a new sign_format=2 (default for new files); v1 files stay
  byte-identical and still verify; unknown formats fail closed. Downgrade
  (flip to v1) also fails since the signature was computed over the v2 message.

Correctness:
- dreamscape laws/imagination (P1): _response_text read getattr(response,
  "content") which is always "" on the live langchain ModelResponse (output
  lives in .result), so Hard-Law enforcement and the imagination failure
  trigger were INERT (fail-open on a governance guard). Read .result (with a
  bare-AIMessage .content fallback) via one shared helper; rebuild a refusal
  via dataclasses.replace(response, result=[AIMessage(...)]). No other
  dreamscape behavior changed.
…unattended shell

Implements reviewed propose-batch items P6/P7. daemon suite 141 pass; ruff
(incl. PLW1514) clean.

- P6: the manual-run, git-push, and webhook endpoints each did
  asyncio.create_task(run_job(...)) directly, bypassing the scheduler's
  _running_jobs overlap guard and BOG_DAEMON_MAX_CONCURRENT_JOBS semaphore — so
  a push/webhook storm could fan out unbounded parallel runs of one job against
  a single workspace/history. Add DaemonScheduler.dispatch(): reserves the slot
  synchronously (skip-if-running -> SKIPPED), then runs under _run_job_safely
  (semaphore-bounded). All three endpoints route through it; the manual
  endpoint still returns its placeholder run immediately.
- P7 (V3-11): the unattended runner built LocalShellBackend(virtual_mode=False)
  with no HITL on triggers that include the externally-reachable, HMAC-only
  webhook path. Non-MANUAL triggers now default to virtual_mode=True (shell/fs
  confined to working_dir); MANUAL (token-authenticated) keeps unrestricted
  real-path shell. Operators opt into unrestricted unattended shell with
  BOG_DAEMON_ALLOW_UNATTENDED_SHELL=1, which logs a WARNING when a
  network-reachable trigger then runs with guardrails disabled.
…writes, DLP, worktree)

Implements reviewed propose-batch items P9/P10/P11/P12/P14/P15/P17/P18/P19/P26.
ruff+format+ty clean; 1806 SDK unit tests pass (the P11 xfail now passes).

Data-integrity:
- multi_edit (P9): chained edits to the SAME file silently dropped earlier
  edits — StateBackend.edit re-read the original and the dict-keyed update map
  overwrote. Thread the working copy forward via a new keyword-only
  base_content arg (BackendProtocol/StateBackend.edit) so edit #2 sees edit #1;
  the async protocol path forwards base_content only when supplied so backends
  that don't accept it (FilesystemBackend) keep working. (P10) set
  status='error' on the ToolMessage when any edit in the batch fails.
- filesystem backend (P12): edit() and upload_files() truncated the target in
  place, so an interrupt/ENOSPC mid-write could empty the original. Write to a
  sibling temp (O_NOFOLLOW|O_CREAT|O_EXCL) + fsync + atomic os.replace via a new
  _atomic_write helper that also refuses to write through a symlink.
- filesystem middleware (P11): multiple write-class tool_calls (write_file/
  edit_file/multi_edit_file) on the SAME file in one AIMessage raced and the
  last-writer-wins reducer clobbered the rest. Add an after_model guard that
  keeps the first such call per file and returns an error ToolMessage for the
  rest, telling the model to sequence them. Distinct files / reads untouched.
  (Flips the long-standing xfail.)
- DLP (P14): redact mode mutated shared LangGraph message content in place,
  corrupting canonical history. Redact a per-call copy and forward via
  request.override — canonical state is preserved (StreetSweeper invariant).

Concurrency / correctness:
- worktree (P18): a cancelled task was resurrected to 'completed' in finally —
  add a terminal-status guard + a 'cancelled' state + best-effort handle.cancel.
  (P19) per-task repo_root captured at registration instead of mutating the
  shared self._working_dir, so concurrent tasks don't race onto the wrong root.
  (P26) merge_with_conflict_report restores the caller's branch on every
  non-success path via try/finally.
- expert engine (P17): assert_fact-derived facts grew unbounded for the engine
  lifetime. Add a keyword-only soft cap (default 5000) that warns once and
  FIFO-evicts the oldest DERIVED facts (never structural tool_call facts).
- graph (P15): enabling result-synthesis while passing an explicit
  ParallelWorktreeMiddleware no longer discards the user's instance — search
  user middleware too and reuse it (kept before ResultSynthesis so requires=
  ordering holds).

Note: P13 (StoreBackend legacy-namespace route-isolation bleed) is intentionally
left as an xfail — the safe fix needs a CompositeBackend route discriminator and
a data-migration decision; deferred rather than risk silent data loss.
…t race, fork lock, oauth state)

Implements reviewed propose-batch items P20/P21/P27/P28/P29/P30/P32.
ruff+format+ty clean; CLI suite 4602 pass.

- orchestrator (P20/P21): parallel subtasks ran via run_coroutine_threadsafe
  against the caller's loop (hard-deadlock if ever entered from the loop
  thread) on the shared default executor (a timeout abandoned the worker
  threads). Rewrite onto a dedicated, bounded ThreadPoolExecutor owned by the
  call — no asyncio, so it's deadlock-free from any thread — with a per-subtask
  wall-clock budget and shutdown(cancel_futures=True) on timeout so abandoned
  work can't saturate the default executor.
- app.py (P27): auto-commit ran AFTER the queue drain (which can spawn the next
  turn's worker), so it could stage a half-written tree from turn N+1. Finalize
  stats + auto-commit BEFORE the drain. (P28) move the critical worker-cleanup
  restoration (_agent_running/_agent_worker reset, input-cursor re-enable) into
  a finally that a cancelled inner await can't skip, so a cancellation can no
  longer leave the input wedged with no diagnostic.
- session_fork (P30): _save_fork read-modify-wrote forks/<parent>.json with no
  lock, losing a record when two CLI processes fork the same parent. Serialize
  with an in-process lock + a cross-process O_EXCL lock file (filelock isn't a
  direct dep) and atomic_write_text.
- dreamscape scheduler (P29): stop() left the slow completion-callback tasks
  running (asyncio teardown "Task destroyed but pending" + abandoned proposer
  write). Snapshot, cancel, and await them (bounded).
- oauth_mcp (P32): exchange_code_for_token never validated the OAuth state
  (CSRF/code-injection). Add keyword-only expected_state/received_state with a
  constant-time check (additive — opt-in until the callback flow is wired).
Ports the deepagents 0.5.0+ backend type layer that bog forked before:
Result dataclasses (Ls/Read/Grep/Glob/Delete/ExecuteOffload), FileData v2
(str content + encoding), delete/adelete on the protocol, and ~16 helpers
in backends/utils.py.

Legacy names (read -> str, ls_info, grep_raw, glob_info) are preserved as
delegating shims with override-detection forwarding in both directions, so
no existing backend or middleware breaks. create_file_data still defaults to
file_format=v1; the backends wave flips it.

Also fixes two live bugs in utils.py: grep iterated characters instead of
lines on v2 content, and a missing modified_at key raised KeyError.

Raises dependency floors to be co-installable with deepagents (wcmatch was
capped <7.0 against upstream's >=10.1, making the two packages unresolvable
in one venv). langchain 1.3.12 / langchain-core 1.4.9 / langchain-anthropic
1.4.8. Glob/permission semantics verified unchanged across wcmatch 6 -> 10.

Adds bog_agents/_api/deprecation.py and middleware/_private_state.py.

Refs PARITY.md waves 1A, 1C.
…overwrite)

Finishes the Wave 1C backend rewrite bog forked before (deepagents 0.5.0).

- FileData v2 is now the default (create_file_data -> content: str + encoding).
  Readers tolerate legacy list[str] with a DeprecationWarning; file_format='v1'
  remains an explicit escape hatch. StoreBackend no longer rejects v2 items as
  corrupt.
- delete/adelete across the protocol and all backends, incl. recursive directory
  delete (StateBackend removes prefixed children without eating sibling keys).
- write() now OVERWRITES an existing path on all six backends, matching upstream.
  Previously StateBackend/StoreBackend/FilesystemBackend errored. This is a
  deliberate, user-visible semantic change: a CompositeBackend mixing a
  FilesystemBackend default with a StateBackend at /memories would otherwise
  accept or reject the same write based only on the routed prefix.
- StateBackend()/StoreBackend(store=...) construct without a runtime; StateBackend
  gets read-your-writes via CONFIG_KEY_READ/SEND; StoreBackend accepts both the
  old (BackendContext) and new (Runtime) NamespaceFactory generations.
- New backends: LangSmithSandbox (SDK-native read/write, CRLF normalization,
  capture offload) and ContextHubBackend (versioned hub-backed filesystem). Both
  lazily import langsmith so lazy-import health holds.
- BaseSandbox gains capture-offload, ARG_MAX-safe edit-via-upload, binary read,
  and native async.

Real bugs fixed along the way:
- grep iterated characters instead of lines on v2 content
- _python_search: '*.py' never matched nested files, and Windows backslash
  relative paths never matched any glob
- CompositeBackend.glob leaked entries from other routes when scoped to a path
- create_file_data silently dropped encoding= under v1, corrupting binary as text
- a missing modified_at key raised KeyError

+302 tests, including a forwarding-matrix suite pinning the legacy<->new backend
shims that were previously unpinned.

Refs PARITY.md wave 1C.
Wave 1B: brings the middleware layer to deepagents 0.6.12 parity and closes
two confirmed permission bypasses.

SECURITY:
- Filesystem deny rules were checked only against a tool call's path argument,
  but ls/glob/grep don't require one -- so `grep(pattern="API_KEY")` with no
  path returned matches (and file contents) from a denied subtree. Deny rules
  now filter ls/glob/grep *results*; interrupt-mode entries pass through
  unfiltered (interrupt means ask-on-access, not hide). Wired into all three
  FilesystemMiddleware construction sites in graph.py -- the filter functions
  existed but were never installed on the real create_agent path -- and pinned
  with an end-to-end regression test driving create_agent(permissions=...).
- The symlinked-skill-directory refusal existed only on the sync _list_skills
  path; the async _alist_skills path (used by ainvoke) had no check, so running
  the agent async bypassed containment entirely. Both paths now route through a
  single _filter_skill_dirs chokepoint.
- Recursive delete of / or /** could blow away a denied subtree; delete is now
  gated by _find_delete_deny_patterns and refused rather than partially run.

Interop surface:
- SystemPrompt/subagent: SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY (SubAgent.response_format
  was silently dropped), public create_sub_agent, state_schema + private-key
  forwarding so rubric/summarization/skills bookkeeping no longer leaks across
  the subagent boundary, LangSmith subagent tracing context.
- New delete filesystem tool (7th FS tool), FsToolName superset, keyword-only
  FilesystemMiddleware params (tools/_permissions/human_message_token_limit_before_evict/
  custom_tool_descriptions).
- Skills: (path, label) tuple sources, system_prompt param, skills_load_errors
  state + prompt-injection-guarded warning block, YAML-list allowed-tools.
- Memory: add_cache_control/system_prompt params, HTML-comment stripping with
  split-tag defense, Trust-and-verification prompt block.
- Summarization: overflow-clip (breaks the same-oversized-tail retry wedge),
  inline-media offload, widened create_summarization_middleware; eviction
  helpers extracted to _message_eviction.py (filesystem.py delegates).
- patch_tool_calls now patches invalid_tool_calls (dangling tool_use blocks were
  rejected by the provider) and returns early when nothing dangles.
- 18 previously-unreachable symbols registered in middleware/_LAZY_IMPORTS.

Fixes the stale v1 FileData TypedDict in middleware/filesystem.py (re-points at
backends.FileData). Flips the tests that asserted these upstream bugs.

Refs PARITY.md waves 1B, 3.
…profiles)

Wave 1A: makes bog-agents a source-level drop-in for the deepagents public API.

- SystemPromptConfig: create_agent/create_deep_agent now accept the prefix/base/
  suffix dict form. base=None drops the built-in base prompt; an omitted base key
  keeps the profile/default base; suffix lands after base, before the profile
  suffix. Previously a dict system_prompt raised TypeError -- the headline interop
  break. The provenance addendum is assembled as its own part so base=None does
  not silently un-prompt bound provenance tools.
- Every symbol in deepagents' __all__ now imports from both bog_agents and
  bog_agents.deepagents (adds SystemPromptConfig, FsToolName, plus create_sub_agent
  and SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY). create_deep_agent gains keyword-only
  max_turns=9999 and widened system_prompt/subagents/response_format annotations to
  mirror upstream.
- recursion_limit is no longer hard-clamped at 1000 (create_agent's native max_turns
  default cap is unchanged; only the deepagents shim defaults to 9999). LangSmith
  metadata key 'versions' -> 'lc_versions'.
- User middleware whose .name collides with a built-in now REPLACES it in place
  (was silently dropped by keep-first dedup).
- GeneralPurposeSubagentProfile(enabled=False) now actually removes the task tool
  and SubAgentMiddleware (was a typed no-op). Profile tool_description_overrides
  reach the built-in tools, and subagents resolve their own per-model harness
  profile instead of inheriting the parent's.
- _models.py: model_matches_spec now compares the provider (openai:gpt-5 no longer
  matches an Anthropic model named gpt-5); adds is_bedrock_model; the hardcoded
  use_responses_api moved below the provider-profile layer so a profile can now
  override it (was a dead override).

Flips the two tests that asserted the recursion-limit and metadata-key bugs; adds
a drop-in compat test covering all 20 upstream public symbols.

Refs PARITY.md wave 1A.
bog_agents/base_prompt.md was a static copy of the system prompt that nothing
loaded -- the live base prompt is the BASE_AGENT_PROMPT constant in graph.py.
It had already drifted (its first line reads 'You are a Bog Agents'). Delete it
and add a regression guard so it can't reappear as a second source of truth.

Refs PARITY.md wave 3 (orphan-base-prompt-md).
Wave 2. The profile registry existed but shipped zero built-in profiles
('This port ships none'), so every model ran with untuned prompts and three
providers were actively broken. Ports the deepagents 0.6.12 profiles:

Harness profiles (system-prompt tuning per model):
- anthropic:claude-opus-4-7 / sonnet-4-6 / haiku-4-5 (parallel-tool-call,
  investigate-before-answering, subagent-usage guidance)
- openai:gpt-5.{1,2,3}-codex (Codex emits preambles and stops early without it)
- nvidia nemotron-3-ultra (8 keys) + a text-form tool-call parser
  (<function=NAME>, <function><name>, bare JSON) -- Nemotron was unusable as an
  agent because nothing parsed its tool calls

Provider profiles:
- openrouter: injects openrouter_provider={'ignore': ['azure']} so reasoning
  models don't route to Azure's stateless /responses beta and break multi-turn
  ('Item with id rs_... not found'). Honors BOG_AGENTS_OPENROUTER_ALLOW_AZURE
  (and the legacy DEEPAGENTS_ name) as an escape hatch.
- openai: use_responses_api=True (now the source of truth; the Wave 1A bridge
  in _models.py no-ops when the profile sets it)
- nvidia: X-BILLING-INVOKE-ORIGIN header

All six register in the _builtin_profiles.py bootstrap, and the loader now also
enumerates the legacy deepagents.{harness,provider}_profiles entry-point groups
so third-party deepagents profile plugins load on bog (native wins on collision).
_harness_profile_for_model normalizes the provider so pre-built model instances
resolve their profile.

Also:
- Bedrock prompt caching: a Bedrock model now pays cached input-token price
  instead of full price every turn. Appended after Anthropic caching, gated on
  is_bedrock_model; langchain_aws stays optional (graceful no-op when absent).
- Video frame read: read_file on a video now samples frames (JPEG blocks +
  window header) when [video] deps are present, falling back to a hint when
  absent -- wired into both sync and async read paths. New [video] extra.

Refs PARITY.md wave 2.
Ports deepagents_code's goal/rubric concept into the SDK. GoalToolsMiddleware
keeps a persistent objective + acceptance-criteria (rubric) in the agent's view,
re-injected via wrap_model_call each turn, with get_goal/get_rubric/update_goal
tools. Completion via update_goal(status='complete') reuses RubricMiddleware's
existing grader verdict rather than forking a grader.

Goal state channels are PrivateStateAttr-marked so they don't leak into the
public I/O schema. Registered lazily in middleware/_LAZY_IMPORTS; not in the
default stack, so canonical middleware ordering is unchanged.

Refs CLI_AUDIT.md (goal-tools-persistent).
…ache bugs

Closes two markup-injection bugs and two correctness bugs found in the CLI audit.

SECURITY:
- The approval dialog (the primary human-trust surface) and the MCP viewer
  rendered attacker-influenceable strings (tool name/description, server-supplied
  MCP metadata) as unescaped Rich markup. A value like [bold red]..[/bold red]
  could restyle/hide the exact text the human approves; a bare [/dim] raised
  MarkupError and crashed the HITL dialog outright. Both now route untrusted text
  through escape_for_display (control-char + bidi/zero-width strip + Rich escape).
- escape_for_display now escapes markup LAST: escaping first let the lone-ESC
  sweep consume the inserted backslash for an ESC-adjacent tag, re-exposing a live
  tag. Also sanitize ANSI/OSC/DCS/C0/C1 control sequences at the tool-display and
  local-context sinks so a malicious tool result can't emit OSC 52 to hijack the
  clipboard.

CORRECTNESS:
- Headless HITL: a malformed interrupt recorded a fail-closed reject but never set
  interrupt_occurred, so the reject was wiped by the next resume iteration and the
  unattended run wedged. Rejects now stage in StreamState.malformed_rejects and are
  delivered exactly once in the resume Command.
- git-branch cache was memoized per-cwd forever, so checkpoints after a mid-session
  branch switch were tagged with the stale branch. Now a short TTL + explicit
  invalidation in the /branch handlers.

Adds an app.py line-count ratchet test (ceiling 17,300) so new surface lands in
commands/*.py + controllers rather than the god class. Saves the CLI audit as
CLI_AUDIT.md.

Refs CLI_AUDIT.md (SEC-1, SEC-2, HITL, git-branch-cache, ARCH-1).
…tion

- Managed ripgrep: on a fresh box with no rg on PATH, download a SHA-256-pinned
  ripgrep 14.1.1 into ~/.bog-agents/bin and prepend it, so grep/search runs at
  full speed instead of the slow Python fallback. Gated behind [tools].auto_install
  (default true) + an offline/air-gapped short-circuit; a checksum mismatch never
  installs. Keeps the existing manual-install warning as the fallback. doctor now
  reports managed-vs-system rg.
- ${VAR} MCP header interpolation: an .mcp.json header value of
  'Authorization: Bearer ${GITHUB_TOKEN}' now resolves from the vault then env
  (with ${VAR:-default}), raising an actionable error when unset -- so tokens
  aren't committed in plaintext.

Regenerates the cli/daemon lockfiles for the SDK dependency-floor bump.

Refs CLI_AUDIT.md (managed-ripgrep, mcp-header-env-interpolation).
… /rubric

- Native reasoning-effort: /effort now maps to each provider's real reasoning
  knob instead of a max_tokens/temperature hack. The old mapping capped a
  reasoning model's output at 1024 tokens on 'low' -- actively truncating it.
  reasoning_effort.py translates the closed effort vocabulary onto Anthropic
  output_config.effort (+ adaptive thinking), OpenAI reasoning.effort, Gemini
  thinking_level, and Fireworks/xAI reasoning_effort, with per-model capability
  detection so only supported levels are offered; non-reasoning models keep the
  max_tokens/temperature fallback.
- External-editor compose: Ctrl+X pops the input buffer into $VISUAL/$EDITOR
  (priority binding, since TextArea binds Ctrl+X to cut) and drops the edited
  result back. utf-8 round-trip, temp file always cleaned up.
- /goal and /rubric: wires the SDK GoalToolsMiddleware into the CLI agent so
  get_goal/get_rubric/update_goal reach the model, backed by a file-persisted
  goal record. /rubric draft|regenerate|accept drafts acceptance criteria from
  the objective (one-shot LLM with a regenerate-on-feedback gate). Headless
  'goal' twin included.

Logic lives in reasoning_effort.py / editor.py / goal_controller.py /
goal_rubric.py (testable without the TUI); app.py handlers stay thin. Ratchet
ceiling bumped 17,300 -> 17,400 deliberately for the goal dispatch handlers.

Refs CLI_AUDIT.md (effort-native-reasoning, ux-external-editor, goal-tools-persistent).
Systematic fix for the recurring registry-drift bug class (P0-G/P0-H family).

- _env_vars.py: single source of truth for all 42 BOG_AGENTS_* environment
  variables as named constants, plus is_env_truthy/env_bool helpers matching the
  existing 1/true/yes/on parsing convention. A completeness drift test greps the
  package for BOG_AGENTS_* literals and fails if any is unregistered, so new vars
  must be declared centrally.
- config_manifest.py: a frozen ConfigOption table (56 options) with __post_init__
  validation that rejects a mistyped/mutable default at import, and resolve_scalar
  with env > toml > default precedence. Credential options derive from
  model_config.PROVIDER_API_KEY_ENV rather than a hand-list, so they stay locked
  to the P0-G sync invariant.
- Upgrades the headless 'config' command from a 3-line stub into a real
  introspection surface: 'config'/'config show' lists every option with its
  resolved value, source, and type; 'config get <key>' resolves one. Secrets are
  redacted and never appear in text or JSON output. Read-only.

Additive: no call-site sweep of existing literals (that mechanical migration is a
separate cleanup); the registry + drift test lock the surface going forward.

Refs CLI_AUDIT.md (env-var-registry, config-manifest).
…er's git index

Two related defects in CheckpointingMiddleware:

1. It ran 'git init' then 'git stash create' with no initial commit. On git
   2.44+, 'git stash create' on an unborn HEAD fails ('you do not have the
   initial commit yet'), so no checkpoint was ever recorded and undo/rewind
   silently did nothing on a fresh working directory. _ensure_initialized now
   makes an initial --allow-empty commit ONLY when HEAD is unborn (a repo with
   existing history is never committed into), and _create_checkpoint anchors at
   HEAD when the tree is clean, so a pre-mutation checkpoint always exists.

2. 'git add -A' staged files into the user's real git index. A user running the
   agent in their own repo would find their whole working tree staged (and, with
   the initial-commit path, an empty checkpoint commit created). All checkpoint
   git commands now run against an isolated throwaway index via GIT_INDEX_FILE,
   so the user's index and history are never modified.

Resolves two pre-existing test_checkpointing_hardening failures on modern git;
rewind/undo/diff tests are unaffected.
Remote MCP servers that require OAuth (e.g. the hosted GitHub MCP server) can
now be authenticated. Uses the official mcp SDK's OAuthClientProvider, which
performs RFC 9728 protected-resource-metadata discovery, Dynamic Client
Registration, PKCE, the authorization-code exchange, and automatic token refresh
internally — nothing is hand-rolled.

- FileTokenStorage persists per-server client registration + tokens under
  ~/.bog-agents/mcp-oauth/<server>.json in a versioned envelope, written via
  atomic_write_text(0600) + _secure_owner_only (cross-platform). Reads are
  fail-closed (size cap, version gate, JSON validation) and never log a token;
  server names are validated so a name can't escape the directory.
- LoopbackCallbackServer: single-use 127.0.0.1 callback listener that captures
  (code, state) for the SDK's CSRF check, with a paste-back fallback for headless
  environments.
- /mcp login <server> runs the browser + loopback flow in a Textual worker (never
  blocks the event loop); /mcp logout and /mcp status manage stored tokens.
- The connection path attaches a provider for a server marked auth:oauth or with
  stored tokens; a 401/RFC-9728 challenge at load now yields an actionable
  'run /mcp login <server>' message instead of an opaque timeout.
- The refresh-only (connection) provider is built with interactive=False: it
  binds NO loopback socket and never opens a browser, so attaching it per reload
  can't leak sockets/threads or pop a browser at startup.

Deletes the dead hand-rolled oauth_mcp.py (and its test) it supersedes. Declares
mcp>=1.27.0 as a direct dependency.

Refs CLI_AUDIT.md (mcp-oauth epic, done spec-compliant).
Adds set_symlink_trust_checker(), consulted at the _filter_skill_dirs
containment chokepoint on both the sync and async skill-listing paths. With no
checker registered, behavior is unchanged (all symlinked skill dirs refused);
the CLI registers a checker backed by its skill trust store so a user can opt a
symlinked skill dir back in. The SDK never imports the CLI. A checker that
raises is treated as refusal (fail-closed).

Refs CLI_AUDIT.md (skill-trust-store).
- Theme system: registers the bog 'peat' palette as a proper Textual Theme
  (default) plus bog-light/abyss/ember built-ins and unlimited user themes from
  config. /theme opens a live-preview selector; /theme <name> switches and
  persists across restarts. The app.tcss palette-override block is removed so
  themes control the palette -- proven byte-identical to the old default (a test
  mounts the app and diffs all 18 CSS variables against the prior hex).
- Skill trust store: /skills trust|revoke|clear|list lets a user explicitly opt a
  symlinked skill directory back in, relaxing the default refuse-all-symlinks
  posture ONLY for approved paths. Trust is per-resolved-path with a SKILL.md
  fingerprint; a post-approval symlink swap or an in-place SKILL.md change is
  re-refused, and a malformed store fails closed (nothing trusted). Persisted
  0600. Honored on both sync and async skill-load paths.
- UX polish: /effort with no argument opens a model-aware level picker (only the
  levels the active model supports); /goal review opens a read-only goal+rubric
  panel; doctor reports managed-vs-system ripgrep, a shadowed-entrypoint check,
  and MCP OAuth signed-in count; the MCP viewer renders untrusted server strings
  via Content.assemble tuples (never markup-parsed) as defense-in-depth.

Refs CLI_AUDIT.md (theme-system, skill-trust-store, effort selector, goal panel,
doctor, mcp-viewer).
…, parser test)

Formatting-only: ruff format rewraps a long git-commit arg line in
checkpointing.py, and an E501 test string in test_tool_call_parser.py is split
across adjacent literals. No behavior change; makes `make lint` clean for CI.
…cket isolation

The loopback callback tests bind a real 127.0.0.1 (inet) socket, which CI's
Linux `pytest --disable-socket --allow-unix-socket` step blocks (only unix
sockets are allowed there). An autouse fixture re-enables sockets per test via
pytest-socket when it is active, and is a no-op under the Windows CI step
(which runs `-p no:socket`). No production code changes.
_resolve_path fully resolves the path (following a symlink at the final
component), which the sandbox-escape check needs but which defeats O_NOFOLLOW
and is_symlink() at the leaf — they end up inspecting the symlink's target, not
the link. As a result:

- write() overwrote a symlink's TARGET instead of being refused (the O_NOFOLLOW
  flag was applied to the already-resolved target path). Regressed when the
  error-on-exists guard was removed for the overwrite semantics.
- delete() of a symlink-to-directory rmtree'd the target's contents instead of
  removing just the link (is_symlink() was false on the resolved target).

Both now validate the sandbox via _resolve_path, then operate on a new
_physical_base() path whose final component is left unresolved, so O_NOFOLLOW
refuses a write-through and a symlinked leaf is unlinked as a link. A symlink
whose target escapes root is still rejected by _resolve_path first.

Caught by two symlink tests that only run on Linux CI (they skip where symlink
creation is unprivileged, e.g. Windows dev boxes).
@draxios
draxios merged commit 813f3ce into main Jul 12, 2026
15 checks passed
@draxios
draxios deleted the chore/resiliency-hardening branch July 12, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant