mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 16:32:21 +02:00
Merge pull request #50 from pewdiepie-archdaemon/feature/runtime-request-authority
feat(runtime): enforce server request authority
This commit is contained in:
@@ -777,6 +777,7 @@ class ScheduledTask(TimestampMixin, Base):
|
|||||||
owner = Column(String, nullable=True, index=True)
|
owner = Column(String, nullable=True, index=True)
|
||||||
name = Column(String, nullable=False, default="Untitled Task")
|
name = Column(String, nullable=False, default="Untitled Task")
|
||||||
prompt = Column(Text, nullable=True) # LLM prompt (for task_type="llm")
|
prompt = Column(Text, nullable=True) # LLM prompt (for task_type="llm")
|
||||||
|
request_authority_json = Column(Text, nullable=True) # server-only admitted request snapshot
|
||||||
task_type = Column(String, default="llm") # "llm" | "action"
|
task_type = Column(String, default="llm") # "llm" | "action"
|
||||||
action = Column(String, nullable=True) # builtin action name (for task_type="action")
|
action = Column(String, nullable=True) # builtin action name (for task_type="action")
|
||||||
schedule = Column(String, nullable=True) # "once", "daily", "weekly", "monthly"
|
schedule = Column(String, nullable=True) # "once", "daily", "weekly", "monthly"
|
||||||
@@ -2335,6 +2336,15 @@ def _migrate_seed_email_account():
|
|||||||
# Any future migrations or schema changes that temporarily violate foreign-key
|
# Any future migrations or schema changes that temporarily violate foreign-key
|
||||||
# constraints will fail. To perform such operations, foreign_keys must be
|
# constraints will fail. To perform such operations, foreign_keys must be
|
||||||
# temporarily disabled around the migration workflow.
|
# temporarily disabled around the migration workflow.
|
||||||
|
def _migrate_add_task_authority_column():
|
||||||
|
"""Retain snapshots after legacy task-table rebuilds; support all DBs."""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
with engine.begin() as conn:
|
||||||
|
columns = {column["name"] for column in inspect(conn).get_columns("scheduled_tasks")}
|
||||||
|
if "request_authority_json" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE scheduled_tasks ADD COLUMN request_authority_json TEXT"))
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""
|
"""
|
||||||
Initialize the database by creating all tables.
|
Initialize the database by creating all tables.
|
||||||
@@ -2412,6 +2422,7 @@ def init_db():
|
|||||||
_migrate_add_oauth_config()
|
_migrate_add_oauth_config()
|
||||||
_migrate_add_email_oauth_columns()
|
_migrate_add_email_oauth_columns()
|
||||||
_migrate_add_task_automation_columns()
|
_migrate_add_task_automation_columns()
|
||||||
|
_migrate_add_task_authority_column()
|
||||||
_migrate_add_disabled_tools()
|
_migrate_add_disabled_tools()
|
||||||
_migrate_add_mcp_oauth_tokens_column()
|
_migrate_add_mcp_oauth_tokens_column()
|
||||||
_migrate_add_task_v2_columns()
|
_migrate_add_task_v2_columns()
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Wave 2: request authority
|
||||||
|
|
||||||
|
Base: `d6c3c98c75e03f70c05ebe4058c6fa12e0395f62`, branch
|
||||||
|
`feature/runtime-request-authority`. Discovery and this plan precede production
|
||||||
|
changes. No later runtime waves are included.
|
||||||
|
|
||||||
|
## Discovered call paths
|
||||||
|
|
||||||
|
`routes/chat_routes.py` parses mode, toggles, workspace, approval decisions and
|
||||||
|
runtime context. User intent can promote Chat to Agent. Owner privileges,
|
||||||
|
global disabled tools, compare/incognito and plan restrictions produce
|
||||||
|
`ToolPolicy`. Compact/native routes resolve `TurnContract`; regular/full models
|
||||||
|
can receive the full enabled schema inventory. The route calls
|
||||||
|
`_stream_agent_with_execution_bridge` and `stream_agent_loop`. Detached runs
|
||||||
|
retain this generator; reconnecting subscribes to it rather than creating a new
|
||||||
|
invocation. Their stream IDs are distinct from journal IDs.
|
||||||
|
|
||||||
|
`src/turn_contract.py` classifies request families and selected tools, resolves
|
||||||
|
exact safe reads, and filters schema availability. Empty-family routing has a
|
||||||
|
legacy core inventory. Warm tools and editor availability may enlarge offers.
|
||||||
|
Transcription, OCR and tasks have narrow selection; static web retrieval may
|
||||||
|
offer private_browser for fallback. These routing choices are not grants.
|
||||||
|
|
||||||
|
`src/agent_loop.py` selects provider/profile transports, parses native or textual
|
||||||
|
tool blocks, repairs calls, performs deterministic preflights and retries, and
|
||||||
|
calls `src/tool_execution.py:execute_tool_block`. Compact preview uses
|
||||||
|
`src/clean_agent_preview.py` but reaches the same dispatcher. The dispatcher
|
||||||
|
checks run security, exact approval, contract membership, disabled tools,
|
||||||
|
ToolPolicy, owner restrictions and bridges before MCP/dynamic/built-in handlers.
|
||||||
|
It forwards policy to dynamic handlers. Legacy loop reconciliation removes
|
||||||
|
disabled names found in a contract's offered inventory. This must not erase a
|
||||||
|
request-authority denial.
|
||||||
|
|
||||||
|
Approvals use `src/tool_approvals.py`. A server record binds tool/content, owner,
|
||||||
|
session, workspace, document id/version/digest, origin run and continuation
|
||||||
|
state. Consume is destructive; claim is one-use. Task/chat scopes bypass an
|
||||||
|
existing run-security gate; they do not define the requested operation classes.
|
||||||
|
Approval continuation executes the sealed action in round zero. Denial exits
|
||||||
|
the route without execution.
|
||||||
|
|
||||||
|
Generic app_api forwards both the internal token and the caller's owner to
|
||||||
|
loopback HTTP. Its blocklist does not exclude Chat/skill approval ingress.
|
||||||
|
Matching owner/session/input bindings alone therefore cannot distinguish a
|
||||||
|
model-produced HTTP decision from a user approval. Those existing ingress
|
||||||
|
points need an explicit internal-tool rejection before consuming approval.
|
||||||
|
Internal HTTP skill-test task bodies likewise cannot mint fresh authority.
|
||||||
|
The same origin rule applies to generic Chat HTTP entry: a loopback generated
|
||||||
|
message is not a new trusted user request, even with correct owner attribution.
|
||||||
|
Both Chat entry points use the existing non-persistence switch for these
|
||||||
|
messages and append explicitly untrusted transient context instead. Later
|
||||||
|
referential turns cannot inherit their operation class as prior user intent.
|
||||||
|
|
||||||
|
Teacher takeover is queued by the student, then owned by the outer adapter in
|
||||||
|
`src/teacher_escalation.py`. It invokes a child loop after the student gate closes
|
||||||
|
and forwards policy, contract, workspace and runtime context. The teacher's
|
||||||
|
synthetic user message is model context, not a new authority source.
|
||||||
|
|
||||||
|
`src/task_scheduler.py:_execute_assistant` composes crew/global restrictions and
|
||||||
|
RAG/default shell availability. `_run_agent_loop` supplies task.prompt or a
|
||||||
|
synthetic override as a user message, with background provider fallback. Exact
|
||||||
|
approval pauses are retired because there is no interactive approver.
|
||||||
|
`_execute_action` invokes BUILTIN_ACTIONS directly, with a separate admin gate.
|
||||||
|
`src/tools/system.py:do_manage_tasks` and `routes/task/task_routes.py` create/edit
|
||||||
|
persisted tasks. No authority snapshot currently survives scheduling.
|
||||||
|
|
||||||
|
Detached Bash dispatch launches `bg_jobs.launch` and returns bg_job_id.
|
||||||
|
`src/bg_monitor.py:_run_followup` appends an explicitly untrusted result to session
|
||||||
|
context and re-enters the loop. It currently forwards neither the originating
|
||||||
|
authority nor its request restrictions. Skill tests/audits in
|
||||||
|
`routes/skills_routes.py` also invoke the loop with task/user messages; generated
|
||||||
|
audit context must not manufacture grants.
|
||||||
|
|
||||||
|
| Question | Current source |
|
||||||
|
| --- | --- |
|
||||||
|
| Requested operation | User intent classifiers, exact safe-read resolver; ultimately parsed/repaired model tool block |
|
||||||
|
| Available capabilities | Registry/MCP inventory, profiles, RAG, TurnContract and request-specific schema filters |
|
||||||
|
| Authorized capabilities | Fragmented policy, privileges, run security and approval checks; no independent envelope |
|
||||||
|
| Restrictions | Route toggles, owner/global policy, plan/compare/incognito, dispatcher owner/workspace checks |
|
||||||
|
| Approval required | Deterministic run-security decision; model output can propose the action but cannot consume approval |
|
||||||
|
| Approval input scope | Server-sealed exact tool/content and owner/session/workspace/document binding |
|
||||||
|
| Nested state | Explicit policy/contract/workspace/context forwarding and journal lineage; no authority snapshot |
|
||||||
|
| Model influence | Tool/input proposals, repairs, recovery choices, generated task/audit prompts; availability currently participates in execution gating |
|
||||||
|
|
||||||
|
## Implementation plan and contract
|
||||||
|
|
||||||
|
1. Add immutable `ExactOperation`, `OperationGrant` and `RequestAuthority` in
|
||||||
|
`src/agent_runtime/authority.py`. Normalize canonical tool identity and JSON
|
||||||
|
inputs (reject duplicate keys/non-finite values); retain exact raw text for
|
||||||
|
Bash/Python, built-in scheduled actions and non-JSON inputs. Grants contain an operation class/tool identity, optional
|
||||||
|
action limits and exact input limits. Authority has its own request id,
|
||||||
|
owner/session/workspace binding, immutable grants and hard denials. It is
|
||||||
|
independent of schema presence, model/profile, stream/journal/receipt IDs.
|
||||||
|
2. Create authority from trusted request text/history and deterministic policy
|
||||||
|
at the chat route before availability reconciliation. The general loop
|
||||||
|
boundary creates it for other trusted direct callers, without consulting
|
||||||
|
schemas, relevant_tools, forced_tools or model output. Authority family
|
||||||
|
inheritance reads only trusted user history. Tool-history exact reads may
|
||||||
|
narrow an already admitted class, never create a class. Unknown intent grants
|
||||||
|
no execution floor. Neutral interaction/planning controls remain explicit.
|
||||||
|
3. Keep semantic classification and availability in TurnContract. Resolve
|
||||||
|
authority grants separately from those semantic facts and hard policy.
|
||||||
|
Exact safe reads restrict action/identifiers. Static web fallback authorizes
|
||||||
|
browser reading/navigation, not arbitrary click/evaluate/form operations.
|
||||||
|
Media/task families do not inherit the shell inventory.
|
||||||
|
4. Bind authority around the whole logical stream, including teacher takeover;
|
||||||
|
forward it explicitly to teacher children and approval records. Children
|
||||||
|
inherit the parent or intersect explicit authority with it. Policy denials
|
||||||
|
union; grants intersect; a child cannot replace the parent scope. Restore
|
||||||
|
the parent on close/error/cancellation. Capture restrictions before legacy
|
||||||
|
offered-tool reconciliation can erase them.
|
||||||
|
5. Enforce at `execute_tool_block`, before approvals are claimed or handlers,
|
||||||
|
bridges/MCP/process dispatch begin. Current policy/disabled gates still win.
|
||||||
|
Missing/malformed dispatcher state fails closed. Standalone callers/tests
|
||||||
|
must supply explicit server authority. Journal ownership remains unchanged;
|
||||||
|
denied calls produce no authoritative execution receipt.
|
||||||
|
6. Existing approvals remain one-use exact claims. Seal the originating
|
||||||
|
authority in the approval digest. Resumption keeps original class limits and
|
||||||
|
current hard restrictions. The approved exact operation may cross its
|
||||||
|
original class boundary only through the consumed, matching server record
|
||||||
|
at that call; it does not mutate authority for subsequent calls. Nested
|
||||||
|
execution cannot use an approval to exceed its parent ceiling. Existing
|
||||||
|
task/chat UI and run-security scope semantics are unchanged.
|
||||||
|
Chat/skill approval ingress rejects validated internal-tool requests before
|
||||||
|
consumption; identity impersonation is not a user approval decision.
|
||||||
|
A shared HTTP factory admits trusted user requests and produces an empty,
|
||||||
|
policy-restricted envelope for known internal-tool Chat/skill requests.
|
||||||
|
7. Persist a server-only authority snapshot and task-input binding on scheduled
|
||||||
|
records. Direct authenticated task ingress can admit its user-supplied task;
|
||||||
|
task creation inside model execution intersects with parent authority.
|
||||||
|
Scheduler overrides, retries and provider fallbacks reuse that snapshot.
|
||||||
|
Missing/stale snapshots grant no tool authority. Newly seeded server-owned
|
||||||
|
housekeeping jobs receive exact snapshots at their static creation point;
|
||||||
|
existing rows are not retrospectively authorized by their names/actions.
|
||||||
|
Internal tool HTTP task payloads cannot become fresh user requests across an
|
||||||
|
ASGI context boundary. Built-in actions receive
|
||||||
|
an exact admission check. Persist detached-job authority in a separate
|
||||||
|
authority sidecar at dispatch; monitor continuations reuse it and current
|
||||||
|
denials. Do not edit bg_jobs/process containment implementation.
|
||||||
|
8. Production files: new authority module; routes/chat_routes.py;
|
||||||
|
src/agent_loop.py; src/tool_execution.py; src/teacher_escalation.py;
|
||||||
|
src/tool_approvals.py; core/database.py; routes/task/task_routes.py;
|
||||||
|
src/tools/system.py; src/task_scheduler.py; src/bg_monitor.py;
|
||||||
|
routes/skills_routes.py. Change preview only if direct-entry binding is
|
||||||
|
required by validation. No TurnContract/profile/schema redesign.
|
||||||
|
9. Shared hotspots: route/loop/dispatch, approvals and task/database integration.
|
||||||
|
One coordinator writes all production files. Keep changes confined to
|
||||||
|
authority creation, forwarding, persistence and admission. Do not modify
|
||||||
|
containment, provenance/effect classification or egress implementation.
|
||||||
|
10. Focused regressions: available schema/bridge/dynamic handler without grants;
|
||||||
|
model-selected unrelated tool/action; explicit class admission; exact read
|
||||||
|
arguments; narrow transcription/OCR/tasks/browser fallback; hard denials
|
||||||
|
despite offered-tool reconciliation; retry/fallback stability; child and
|
||||||
|
teacher non-widening and restoration; malformed/missing state; exact
|
||||||
|
approval mismatch/replay and continuation scope; scheduled snapshot/input
|
||||||
|
binding and synthetic override; detached followup inheritance; journal
|
||||||
|
denial evidence. Preserve existing policy-forwarding and Ajax assertions.
|
||||||
|
|
||||||
|
Validation: new focused tests; existing contract/policy/capability/profile
|
||||||
|
tests; scripts/validate_runtime_wave1.sh; broad affected runtime tests; full
|
||||||
|
pytest; compileall; JS/MJS syntax; diff check and conflict-marker scan. Any
|
||||||
|
production edit after full pytest requires affected tests and full pytest again.
|
||||||
|
|
||||||
|
## Implemented boundaries and remaining limits
|
||||||
|
|
||||||
|
The preview entry also binds authority because it supports direct callers.
|
||||||
|
Research task admission binds the snapshot around the researcher, so nested
|
||||||
|
execution cannot infer grants from generated research context. LAN lookup
|
||||||
|
intent has a narrow host_shell-only admission rule; it adds neither Bash nor
|
||||||
|
Python and does not alter Ajax schemas or profiles.
|
||||||
|
|
||||||
|
Scheduled loop entry explicitly forwards the restored workspace as well as
|
||||||
|
the envelope; rebinding the continuation session never drops confinement to
|
||||||
|
the original workspace. Only the actual server Bash launch seals a detached
|
||||||
|
job sidecar. A handler/bridge result claiming a job id cannot create one.
|
||||||
|
|
||||||
|
Snapshots are trusted server state, stored in the task database and detached
|
||||||
|
job authority sidecars. Missing, malformed, changed-input, wrong-owner or
|
||||||
|
wrong-session snapshots fail closed. Legacy tasks need a trusted task-input
|
||||||
|
save to obtain a snapshot; legacy detached jobs have no execution grants on
|
||||||
|
followup. No broad backfill, authority-mode UI, containment, effect/egress or
|
||||||
|
receipt/journal redesign is included. Sidecars follow the detached job's server
|
||||||
|
storage trust assumptions; retention/integrity hardening is outside this slice.
|
||||||
|
|
||||||
|
Class admission deliberately reuses the deterministic semantic classifiers.
|
||||||
|
Unrecognized intent has only explicit ask_user/update_plan controls. This can
|
||||||
|
deny unsupported phrasing and generated default skill tests/audits; model
|
||||||
|
prompts and tool inventory cannot repair that denial. Existing exact approvals
|
||||||
|
can admit one sealed root operation, never widen subsequent calls or nested
|
||||||
|
authority. They still require the existing armed security context, matching
|
||||||
|
bindings, one-use claim, document checks and current hard restrictions.
|
||||||
|
|
||||||
|
Standalone dispatcher test fixtures now supply explicit registry grants to
|
||||||
|
continue exercising their original handler/policy/confinement assertions.
|
||||||
|
New authority tests use the raw dispatcher and prove denial before dispatch.
|
||||||
|
|
||||||
|
## File ownership and reasons
|
||||||
|
|
||||||
|
| Production file | Wave 2 change |
|
||||||
|
| --- | --- |
|
||||||
|
| src/agent_runtime/authority.py | Immutable intent/admission/operation API, trusted factory, intersection/context binding, task/job snapshots |
|
||||||
|
| routes/chat_routes.py | Capture authority before availability reconciliation; pass it into execution; guard approval ingress |
|
||||||
|
| src/agent_loop.py | Bind logical-invocation authority; capture it in approvals and teacher takeover |
|
||||||
|
| src/tool_execution.py | Normalize/check operations before dispatch and approval claims; bind handler context; seal actual detached launch |
|
||||||
|
| src/teacher_escalation.py | Explicit child/approval inheritance without synthetic-prompt grants |
|
||||||
|
| src/tool_approvals.py | Bind immutable originating authority into exact approval digest |
|
||||||
|
| src/clean_agent_preview.py | Bind authority at the supported direct preview entry |
|
||||||
|
| core/database.py | Add nullable server-only scheduled snapshot column and additive migration |
|
||||||
|
| routes/task/task_routes.py | Seal direct user task inputs; deny fresh grants to internal-tool HTTP payloads |
|
||||||
|
| src/tools/system.py | Cap model-created/edited task snapshots by active authority |
|
||||||
|
| src/task_scheduler.py | Restore original scope/workspace for loops, admit exact built-ins/research, seal new static defaults |
|
||||||
|
| src/bg_monitor.py | Restore original detached-job scope and current hard restrictions |
|
||||||
|
| routes/skills_routes.py | Separate explicit user task authority from generated/internal skill prompts; guard approval ingress |
|
||||||
|
|
||||||
|
Shared hotspots touched: chat routes, agent loop, central dispatcher, preview,
|
||||||
|
teacher escalation, approvals, task CRUD/scheduler/system handlers, database,
|
||||||
|
background monitor and skill entry routes. All production edits have one writer.
|
||||||
|
TurnContract, tool schemas, model profiles, journal/completion foundations,
|
||||||
|
bg_jobs/process containment and effect/egress implementations are untouched.
|
||||||
|
|
||||||
|
`tests/test_request_authority.py` adds the focused authority regressions.
|
||||||
|
`tests/runtime_evidence_helpers.py` adds explicit standalone server fixture
|
||||||
|
grants. Original assertions are preserved in these adapted fixture suites:
|
||||||
|
|
||||||
|
- tests/test_agent_external_tool_schemas.py
|
||||||
|
- tests/test_ask_user_tool.py
|
||||||
|
- tests/test_client_tool_routing.py
|
||||||
|
- tests/test_edit_file.py
|
||||||
|
- tests/test_execution_bridge.py
|
||||||
|
- tests/test_external_context_tool_gate.py
|
||||||
|
- tests/test_image_creation_routing.py
|
||||||
|
- tests/test_review_regressions.py
|
||||||
|
- tests/test_runtime_evidence_contract.py
|
||||||
|
- tests/test_task_cookbook_admin_gate.py
|
||||||
|
- tests/test_task_scheduler_cancel.py
|
||||||
|
- tests/test_tool_approvals.py
|
||||||
|
- tests/test_tool_path_confinement.py
|
||||||
|
- tests/test_tool_policy.py
|
||||||
|
- tests/test_turn_contract.py
|
||||||
|
- tests/test_turn_contract_integration.py
|
||||||
|
- tests/test_update_plan_tool.py
|
||||||
|
- tests/test_weather_search_recovery.py
|
||||||
|
- tests/test_workspace_confine.py
|
||||||
|
|
||||||
|
`website/configuration-reference.md` is regenerated solely to update the
|
||||||
|
chat-route environment-read line number. This document records discovery,
|
||||||
|
the pre-edit plan, implementation boundaries and file ownership. The validation
|
||||||
|
report records final commands/results. No production files in parallel lanes
|
||||||
|
are claimed.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Wave 2 final validation
|
||||||
|
|
||||||
|
Worktree: `odysseus-runtime-request-authority`; branch:
|
||||||
|
`feature/runtime-request-authority`.
|
||||||
|
Starting SHA: `d6c3c98c75e03f70c05ebe4058c6fa12e0395f62`.
|
||||||
|
The final SHA is the local commit containing this report, returned in the final
|
||||||
|
implementation report. No rebase, merge, push or PR was performed.
|
||||||
|
|
||||||
|
All results below apply to the final production code. The last production
|
||||||
|
changes addressed internal HTTP request/approval origin and transient untrusted
|
||||||
|
Chat context. Focused, Wave 1.1, broad runtime and full pytest were rerun after
|
||||||
|
those changes. Subsequent edits only recorded results and removed temporary
|
||||||
|
validation logs.
|
||||||
|
|
||||||
|
| Gate | Final result |
|
||||||
|
| --- | --- |
|
||||||
|
| New Wave 2 authority tests | 58 passed, 1 warning; 1.23s |
|
||||||
|
| Relevant contract/policy/approval/capability/Ajax/task/background tests | 1500 passed, 28 skipped, 1 warning; 30.55s |
|
||||||
|
| Wave 1.1 validation script | 2292 passed, 1 warning; 65.75s |
|
||||||
|
| Broad affected runtime suite | 3079 passed, 28 skipped, 1 warning; 92.83s |
|
||||||
|
| Full pytest | 11644 passed, 54 skipped, 2 xfailed, 182 warnings, 6 subtests passed; 444.40s |
|
||||||
|
| Python compileall | Passed |
|
||||||
|
| JS/MJS syntax | Passed for all 361 tracked files |
|
||||||
|
| Git whitespace gate | Passed |
|
||||||
|
| Conflict-marker scan | Passed |
|
||||||
|
|
||||||
|
The existing release smoke hook skipped because `APP_PORT` was unset; no live
|
||||||
|
instance was driven. Full pytest includes its existing skips and expected
|
||||||
|
failures. Warnings are retained in the local raw log. Missing development test
|
||||||
|
dependencies and Playwright Chromium were installed locally, without changing
|
||||||
|
project dependency declarations. No global dotenv-disable override was used.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ODYSSEUS_TEST_STATIC_PORT=0 .venv/bin/python -m pytest -q tests/test_request_authority.py
|
||||||
|
|
||||||
|
ODYSSEUS_TEST_STATIC_PORT=0 .venv/bin/python -m pytest -q tests/test_request_authority.py tests/test_turn_contract*.py tests/test_tool_policy.py tests/test_tool_approval*.py tests/test_execution_capabilities.py tests/test_ajax*.py tests/test_task_*.py tests/test_bg_*.py
|
||||||
|
|
||||||
|
ODYSSEUS_TEST_PYTHON="$PWD/.venv/bin/python" bash scripts/validate_runtime_wave1.sh
|
||||||
|
|
||||||
|
ODYSSEUS_TEST_STATIC_PORT=0 .venv/bin/python -m pytest -q tests/test_request_authority.py tests/test_agent_*.py tests/test_turn_contract*.py tests/test_tool_policy.py tests/test_tool_approval*.py tests/test_task_*.py tests/test_bg_*.py tests/test_*completion*.py tests/test_foreground_model_routing.py tests/test_client_tool_routing.py tests/test_workspace_confine.py tests/test_product_turn_contract_route.py tests/test_execution_bridge.py tests/test_execution_capabilities.py tests/test_ajax*.py tests/test_external_context_tool_gate.py tests/test_tool_path_confinement.py tests/test_edit_file.py tests/test_runtime_evidence_contract.py tests/test_review_regressions.py tests/test_image_creation_routing.py tests/test_ask_user_tool.py tests/test_update_plan_tool.py tests/test_weather_search_recovery.py tests/test_clean_agent_preview.py tests/test_skill_audit*.py tests/test_preview_execution_evidence.py
|
||||||
|
|
||||||
|
ODYSSEUS_TEST_STATIC_PORT=0 .venv/bin/python -m pytest -q
|
||||||
|
|
||||||
|
.venv/bin/python -m compileall -q -x '(^|/)(\.venv|\.git|node_modules|data|logs|uploads)/' .
|
||||||
|
git ls-files -z '*.js' '*.mjs' | xargs -0 -n 1 node --check
|
||||||
|
git diff --check
|
||||||
|
# Staged whitespace check used --cached --check with all 37 changed paths explicit.
|
||||||
|
git grep --cached -l -E '^(<<<<<<< |=======$|>>>>>>> )' -- '*.py' '*.js' '*.mjs' '*.html' '*.css' '*.json' '*.md' '*.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Conflict-marker grep returns exit 1 with no matches on success.
|
||||||
|
The context firewall rejected the unbounded staged whitespace command before
|
||||||
|
execution; the exact-path check passed. No admitted source inspection was
|
||||||
|
blocked by staging.
|
||||||
|
Local raw validation outputs are archived under the ignored
|
||||||
|
`.venv/wave2-validation/` directory; they are not committed.
|
||||||
|
|
||||||
|
## Regression scope and limits
|
||||||
|
|
||||||
|
The 58 authority tests cover schema/handler/model-selection non-authority,
|
||||||
|
narrow media/tasks/browser behavior, exact reads, deterministic grants, hard
|
||||||
|
denials, malformed/missing state, retry and nested inheritance, teacher
|
||||||
|
forwarding, exact approval scope/replay/digest, scheduled input sealing and
|
||||||
|
workspace restoration, detached followups and actual-launch-only sealing,
|
||||||
|
internal HTTP origin, untrusted Chat persistence, and denied-call journal
|
||||||
|
completion evidence. Existing fixture assertions remain intact; standalone
|
||||||
|
dispatch fixtures now provide explicit server authority.
|
||||||
|
|
||||||
|
Remaining limits: class admission uses deterministic request classifiers and
|
||||||
|
can reject unsupported phrasing; legacy task/job snapshots fail closed until
|
||||||
|
trusted resealing; snapshots assume trusted server database/job storage;
|
||||||
|
sidecar retention hardening is deferred. Existing approvals can admit one exact
|
||||||
|
root operation without granting subsequent or nested operations.
|
||||||
|
|
||||||
|
No Wave 3, 3-S, 4, 5 or 6 work was started. No containment, effect/egress,
|
||||||
|
provenance, authority-mode UI, journal or completion-foundation redesign is
|
||||||
|
included. File ownership and the discovery/implementation contract are recorded
|
||||||
|
in [wave-2-request-authority.md](wave-2-request-authority.md).
|
||||||
+38
-1
@@ -86,6 +86,7 @@ from src.model_profiles import (
|
|||||||
tool_schema_profile,
|
tool_schema_profile,
|
||||||
)
|
)
|
||||||
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
|
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
|
||||||
|
from src.agent_runtime.authority import is_internal_tool_request, request_authority_for_http
|
||||||
from src.turn_contract import (
|
from src.turn_contract import (
|
||||||
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
|
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
|
||||||
requested_capabilities, resolve_turn_contract,
|
requested_capabilities, resolve_turn_contract,
|
||||||
@@ -95,6 +96,15 @@ from src.turn_contract import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_internal_chat_context(ctx, message):
|
||||||
|
tagged = untrusted_context_message("internal tool request", message)
|
||||||
|
ctx.messages.append(tagged)
|
||||||
|
routed = getattr(ctx, "route_messages", None)
|
||||||
|
if routed is not None and routed is not ctx.messages:
|
||||||
|
routed.append(tagged)
|
||||||
|
|
||||||
|
|
||||||
# Track active streams for partial-save safety net
|
# Track active streams for partial-save safety net
|
||||||
_active_streams: Dict[str, dict] = {}
|
_active_streams: Dict[str, dict] = {}
|
||||||
|
|
||||||
@@ -2196,7 +2206,10 @@ def setup_chat_routes(
|
|||||||
webhook_manager=webhook_manager,
|
webhook_manager=webhook_manager,
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
defer_context_shaping=foreground_policy.enabled,
|
defer_context_shaping=foreground_policy.enabled,
|
||||||
|
persist_user_message=not is_internal_tool_request(request),
|
||||||
)
|
)
|
||||||
|
if is_internal_tool_request(request):
|
||||||
|
_append_internal_chat_context(ctx, message)
|
||||||
|
|
||||||
# Research injection
|
# Research injection
|
||||||
research_blocked_by_policy = (
|
research_blocked_by_policy = (
|
||||||
@@ -2648,6 +2661,8 @@ def setup_chat_routes(
|
|||||||
)
|
)
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
if tool_approval_id:
|
if tool_approval_id:
|
||||||
|
from src.agent_runtime.authority import require_user_approval_request
|
||||||
|
require_user_approval_request(request)
|
||||||
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
||||||
normalized_owner = str(owner or "").strip().casefold()
|
normalized_owner = str(owner or "").strip().casefold()
|
||||||
if (
|
if (
|
||||||
@@ -2907,10 +2922,12 @@ def setup_chat_routes(
|
|||||||
and pending_tool_approval.continuation_query
|
and pending_tool_approval.continuation_query
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
persist_user_message=not tool_approval_continuation,
|
persist_user_message=not tool_approval_continuation and not is_internal_tool_request(request),
|
||||||
interaction_mode=chat_mode,
|
interaction_mode=chat_mode,
|
||||||
auto_escalated=auto_escalated,
|
auto_escalated=auto_escalated,
|
||||||
)
|
)
|
||||||
|
if is_internal_tool_request(request):
|
||||||
|
_append_internal_chat_context(ctx, message)
|
||||||
|
|
||||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||||
|
|
||||||
@@ -3349,6 +3366,24 @@ def setup_chat_routes(
|
|||||||
"manage_documents", "create_document", "edit_document", "update_document",
|
"manage_documents", "create_document", "edit_document", "update_document",
|
||||||
}.issubset(disabled_tools),
|
}.issubset(disabled_tools),
|
||||||
}
|
}
|
||||||
|
# Capture permission state before schema selection/reconciliation.
|
||||||
|
# Only deterministic request intent supplies grants, never inventory.
|
||||||
|
_request_authority = request_authority_for_http(
|
||||||
|
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||||
|
history=_turn_history, policy=tool_policy,
|
||||||
|
active_document=bool(active_doc),
|
||||||
|
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||||
|
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||||
|
capabilities=({'search_browser'} if (
|
||||||
|
_explicit_browser_intent or _external_discovery_intent
|
||||||
|
) else ()),
|
||||||
|
)
|
||||||
|
if exact_tool_approval is not None:
|
||||||
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
|
_request_authority = (
|
||||||
|
exact_tool_approval.pending.request_authority
|
||||||
|
or RequestAuthority.empty(owner=_user, session_id=session, workspace=workspace)
|
||||||
|
).restrict(tool_policy)
|
||||||
_turn_contract = None
|
_turn_contract = None
|
||||||
# Image models execute directly, not through the text-agent inventory.
|
# Image models execute directly, not through the text-agent inventory.
|
||||||
# Keep the permission policy above, but do not apply routing omissions
|
# Keep the permission policy above, but do not apply routing omissions
|
||||||
@@ -3477,6 +3512,7 @@ def setup_chat_routes(
|
|||||||
warm_tools=_warm_tools,
|
warm_tools=_warm_tools,
|
||||||
message=message, history=getattr(sess, "history", []) or [],
|
message=message, history=getattr(sess, "history", []) or [],
|
||||||
)
|
)
|
||||||
|
_request_authority = _request_authority.restrict(_contract_policy)
|
||||||
# Resolution already applies user, owner, and global policy. An
|
# Resolution already applies user, owner, and global policy. An
|
||||||
# admitted tool must not later be rejected by the stale
|
# admitted tool must not later be rejected by the stale
|
||||||
# pre-contract disabled snapshot during execution.
|
# pre-contract disabled snapshot during execution.
|
||||||
@@ -4415,6 +4451,7 @@ def setup_chat_routes(
|
|||||||
cwd=_agent_turn_cwd(sess, client_runtime_context),
|
cwd=_agent_turn_cwd(sess, client_runtime_context),
|
||||||
forced_tools=_forced_tools,
|
forced_tools=_forced_tools,
|
||||||
turn_contract=_turn_contract,
|
turn_contract=_turn_contract,
|
||||||
|
request_authority=_request_authority,
|
||||||
uploaded_files=ctx.uploaded_files,
|
uploaded_files=ctx.uploaded_files,
|
||||||
defer_context_shaping=_foreground_policy.enabled,
|
defer_context_shaping=_foreground_policy.enabled,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
|
|||||||
+16
-1
@@ -534,11 +534,13 @@ async def _run_skill_test_job(
|
|||||||
messages=None,
|
messages=None,
|
||||||
transcript=None,
|
transcript=None,
|
||||||
exact_approval=None,
|
exact_approval=None,
|
||||||
|
request_authority=None,
|
||||||
):
|
):
|
||||||
"""Background coroutine: run the skill in an agent loop, capture a condensed
|
"""Background coroutine: run the skill in an agent loop, capture a condensed
|
||||||
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
|
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
|
||||||
import json as _json
|
import json as _json
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
|
|
||||||
job = _skill_test_jobs.get(key)
|
job = _skill_test_jobs.get(key)
|
||||||
if job is None:
|
if job is None:
|
||||||
@@ -559,6 +561,9 @@ async def _run_skill_test_job(
|
|||||||
url, model, messages, headers=headers,
|
url, model, messages, headers=headers,
|
||||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||||
exact_approval=exact_approval,
|
exact_approval=exact_approval,
|
||||||
|
request_authority=(request_authority or (
|
||||||
|
exact_approval.pending.request_authority if exact_approval is not None else None
|
||||||
|
) or RequestAuthority.empty(owner=owner)),
|
||||||
):
|
):
|
||||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||||
continue
|
continue
|
||||||
@@ -1039,6 +1044,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
|||||||
"""Run one audit arm in the agent loop; return transcript, stats, approval."""
|
"""Run one audit arm in the agent loop; return transcript, stats, approval."""
|
||||||
import json as _json
|
import json as _json
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
|
from src.agent_runtime.authority import RequestAuthority, active_request_authority
|
||||||
transcript = []
|
transcript = []
|
||||||
approval_required = None
|
approval_required = None
|
||||||
stats = {"turns": 0, "tool_calls": 0}
|
stats = {"turns": 0, "tool_calls": 0}
|
||||||
@@ -1051,6 +1057,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
|||||||
url, model, messages, headers=headers,
|
url, model, messages, headers=headers,
|
||||||
temperature=0.3, max_tokens=4096, max_rounds=8,
|
temperature=0.3, max_tokens=4096, max_rounds=8,
|
||||||
owner=owner, workload=workload, suppress_skills=True,
|
owner=owner, workload=workload, suppress_skills=True,
|
||||||
|
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
|
||||||
):
|
):
|
||||||
# Streams can include an SSE event line before the data line,
|
# Streams can include an SSE event line before the data line,
|
||||||
# notably `event: error`. Do not silently discard those failures.
|
# notably `event: error`. Do not silently discard those failures.
|
||||||
@@ -2001,6 +2008,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
task = (body.get("task") or "").strip()
|
task = (body.get("task") or "").strip()
|
||||||
|
from src.agent_runtime.authority import RequestAuthority, request_authority_for_http
|
||||||
|
request_authority = (request_authority_for_http(request, task, owner=user)
|
||||||
|
if task else RequestAuthority.empty(owner=user))
|
||||||
|
|
||||||
skills = skills_manager.load(owner=user)
|
skills = skills_manager.load(owner=user)
|
||||||
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
|
||||||
@@ -2064,9 +2074,12 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
"model": model,
|
"model": model,
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"owner": user,
|
"owner": user,
|
||||||
|
"request_authority": request_authority,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
|
_asyncio.create_task(_run_skill_test_job(
|
||||||
|
key, name, md, task, url, model, headers, user, skills_manager,
|
||||||
|
request_authority=request_authority))
|
||||||
return {"ok": True, "status": "running", "skill": name, "model": model}
|
return {"ok": True, "status": "running", "skill": name, "model": model}
|
||||||
|
|
||||||
@router.post("/{skill_id}/test-approval")
|
@router.post("/{skill_id}/test-approval")
|
||||||
@@ -2074,6 +2087,8 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
"""Resume a manual skill test with one exact server-sealed action."""
|
"""Resume a manual skill test with one exact server-sealed action."""
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
from src.tool_approvals import tool_approval_store
|
from src.tool_approvals import tool_approval_store
|
||||||
|
from src.agent_runtime.authority import require_user_approval_request
|
||||||
|
require_user_approval_request(request)
|
||||||
|
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
skills = skills_manager.load(owner=user)
|
skills = skills_manager.load(owner=user)
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ from routes.prefs_routes import _load_for_user, _save_for_user
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _seal_request_task_authority(request, prompt, task_type, action, owner):
|
||||||
|
from src.agent_runtime.authority import MISSING_AUTHORITY, is_internal_tool_request, seal_task_authority
|
||||||
|
# A tool HTTP call starts another ASGI context. Its model-produced body is
|
||||||
|
# not a fresh user request, even though the internal token authenticates it.
|
||||||
|
parent = None if is_internal_tool_request(request) else MISSING_AUTHORITY
|
||||||
|
return seal_task_authority(prompt, task_type, action, owner=owner, parent_authority=parent)
|
||||||
|
|
||||||
|
|
||||||
def _maybe_cascade_calendar_event(task) -> None:
|
def _maybe_cascade_calendar_event(task) -> None:
|
||||||
"""Delete the linked calendar event when a cookbook_serve task is
|
"""Delete the linked calendar event when a cookbook_serve task is
|
||||||
removed. Two lookup strategies:
|
removed. Two lookup strategies:
|
||||||
@@ -530,6 +538,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
owner=user,
|
owner=user,
|
||||||
name=name,
|
name=name,
|
||||||
prompt=req.prompt,
|
prompt=req.prompt,
|
||||||
|
request_authority_json=_seal_request_task_authority(
|
||||||
|
request, req.prompt, req.task_type, req.action, user),
|
||||||
task_type=req.task_type,
|
task_type=req.task_type,
|
||||||
action=req.action,
|
action=req.action,
|
||||||
schedule=req.schedule,
|
schedule=req.schedule,
|
||||||
@@ -737,6 +747,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
task.task_type = req.task_type
|
task.task_type = req.task_type
|
||||||
if req.action is not None:
|
if req.action is not None:
|
||||||
task.action = req.action
|
task.action = req.action
|
||||||
|
if any(value is not None for value in (req.prompt, req.task_type, req.action)):
|
||||||
|
task.request_authority_json = _seal_request_task_authority(
|
||||||
|
request, task.prompt, task.task_type, task.action, user)
|
||||||
if req.output_target is not None:
|
if req.output_target is not None:
|
||||||
task.output_target = req.output_target
|
task.output_target = req.output_target
|
||||||
if req.model is not None:
|
if req.model is not None:
|
||||||
|
|||||||
@@ -20375,6 +20375,10 @@ def _blocks_before_inference(turn_contract) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
|
||||||
|
|
||||||
|
|
||||||
|
@with_request_authority
|
||||||
@with_turn_contract
|
@with_turn_contract
|
||||||
@with_teacher_takeover
|
@with_teacher_takeover
|
||||||
@with_completion_gate
|
@with_completion_gate
|
||||||
@@ -20420,6 +20424,7 @@ async def stream_agent_loop(
|
|||||||
suppress_skills: bool = False,
|
suppress_skills: bool = False,
|
||||||
reasoning_effort: Optional[str] = None,
|
reasoning_effort: Optional[str] = None,
|
||||||
_parent_run_id: Optional[str] = None,
|
_parent_run_id: Optional[str] = None,
|
||||||
|
request_authority=MISSING_AUTHORITY,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Streaming agent loop generator.
|
"""Streaming agent loop generator.
|
||||||
|
|
||||||
@@ -32758,6 +32763,7 @@ async def stream_agent_loop(
|
|||||||
block.tool_type, block.content
|
block.tool_type, block.content
|
||||||
),
|
),
|
||||||
request_text=_last_user,
|
request_text=_last_user,
|
||||||
|
request_authority=active_request_authority(),
|
||||||
)
|
)
|
||||||
desc = f"{block.tool_type}: APPROVAL REQUIRED"
|
desc = f"{block.tool_type}: APPROVAL REQUIRED"
|
||||||
result = {
|
result = {
|
||||||
@@ -37372,6 +37378,7 @@ async def stream_agent_loop(
|
|||||||
active_document=active_document,
|
active_document=active_document,
|
||||||
active_email=active_email,
|
active_email=active_email,
|
||||||
turn_contract=turn_contract,
|
turn_contract=turn_contract,
|
||||||
|
request_authority=active_request_authority(),
|
||||||
external_untrusted_context_seen=run_security.external_untrusted_context_seen,
|
external_untrusted_context_seen=run_security.external_untrusted_context_seen,
|
||||||
client_runtime_context=client_runtime_context,
|
client_runtime_context=client_runtime_context,
|
||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
"""Server-owned request admission, independent of model tool availability."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import aclosing, contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import signature
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||||
|
from src.turn_contract import (
|
||||||
|
FAMILY_TOOLS, canonical_tool, requested_capabilities,
|
||||||
|
RequiredReadOperation, required_read_operation_for_request, selected_tools_for_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner(value):
|
||||||
|
return str(value or "").strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _pairs(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ValueError("Duplicate operation argument")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_constant(value):
|
||||||
|
raise ValueError("Non-finite operation argument")
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value):
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExactOperation:
|
||||||
|
tool: str
|
||||||
|
input: str
|
||||||
|
action: str | None = None
|
||||||
|
transport_tool: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize(cls, tool, content):
|
||||||
|
if not isinstance(tool, str) or not tool.strip() or not isinstance(content, str):
|
||||||
|
raise ValueError("Operation requires a tool name and string input")
|
||||||
|
transport_tool = tool.strip()
|
||||||
|
tool = canonical_tool(transport_tool)
|
||||||
|
normalized = content
|
||||||
|
payload = None
|
||||||
|
raw_input = tool in {"bash", "python"} or tool.startswith("scheduled__")
|
||||||
|
if not raw_input and content.lstrip().startswith("{"):
|
||||||
|
payload = json.loads(content, object_pairs_hook=_pairs, parse_constant=_invalid_constant)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Structured tool input must be an object")
|
||||||
|
normalized = _json(payload)
|
||||||
|
# Reuse the runtime's existing multiplexed-action normalization; this
|
||||||
|
# classifies input and never grants permission or changes the input.
|
||||||
|
from src.tool_capabilities import _action_from_content
|
||||||
|
action = _action_from_content(tool, content)
|
||||||
|
if tool == "private_browser" and isinstance(payload, dict):
|
||||||
|
action = payload.get("action")
|
||||||
|
if action is not None and not isinstance(action, str):
|
||||||
|
raise ValueError("Browser action must be a string")
|
||||||
|
action = action.strip().casefold() if action else None
|
||||||
|
return cls(tool, normalized, action, transport_tool)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationGrant:
|
||||||
|
tool: str
|
||||||
|
actions: frozenset[str] | None = None
|
||||||
|
inputs: frozenset[str] | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not isinstance(self.tool, str) or not self.tool or canonical_tool(self.tool) != self.tool:
|
||||||
|
raise ValueError("Grant requires a canonical tool identity")
|
||||||
|
for values in (self.actions, self.inputs):
|
||||||
|
if values is not None and (not isinstance(values, frozenset)
|
||||||
|
or any(not isinstance(v, str) for v in values)):
|
||||||
|
raise TypeError("Grant limits must be immutable string sets")
|
||||||
|
|
||||||
|
def permits(self, operation):
|
||||||
|
return (self.tool == operation.tool
|
||||||
|
and (self.actions is None or operation.action in self.actions)
|
||||||
|
and (self.inputs is None or operation.input in self.inputs))
|
||||||
|
|
||||||
|
def intersect(self, other):
|
||||||
|
if self.tool != other.tool:
|
||||||
|
raise ValueError("Cannot intersect different operation classes")
|
||||||
|
def limits(left, right):
|
||||||
|
return right if left is None else left if right is None else left & right
|
||||||
|
return OperationGrant(self.tool, limits(self.actions, other.actions),
|
||||||
|
limits(self.inputs, other.inputs))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestAuthority:
|
||||||
|
request_id: str
|
||||||
|
owner: str
|
||||||
|
session_id: str
|
||||||
|
workspace: str
|
||||||
|
grants: tuple[OperationGrant, ...] = ()
|
||||||
|
denied: frozenset[str] = frozenset()
|
||||||
|
block_all: bool = False
|
||||||
|
disable_mcp: bool = False
|
||||||
|
inherited: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if (not isinstance(self.request_id, str) or not self.request_id
|
||||||
|
or any(not isinstance(v, str) for v in (self.owner, self.session_id, self.workspace))
|
||||||
|
or not isinstance(self.grants, tuple)
|
||||||
|
or any(not isinstance(g, OperationGrant) for g in self.grants)
|
||||||
|
or len({g.tool for g in self.grants}) != len(self.grants)
|
||||||
|
or not isinstance(self.denied, frozenset)
|
||||||
|
or any(not isinstance(n, str) or canonical_tool(n) != n for n in self.denied)
|
||||||
|
or any(type(v) is not bool for v in (self.block_all, self.disable_mcp, self.inherited))):
|
||||||
|
raise ValueError("Malformed request authority")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
||||||
|
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""))
|
||||||
|
|
||||||
|
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||||
|
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||||
|
and self.workspace == str(workspace or ""))
|
||||||
|
|
||||||
|
def restricted(self, operation):
|
||||||
|
return (self.block_all or operation.tool in self.denied
|
||||||
|
or (self.disable_mcp and (operation.tool.startswith("mcp__")
|
||||||
|
or operation.transport_tool.startswith("mcp__"))))
|
||||||
|
|
||||||
|
def permits(self, operation):
|
||||||
|
return not self.restricted(operation) and any(g.permits(operation) for g in self.grants)
|
||||||
|
|
||||||
|
def restrict(self, policy=None, disabled_tools=()):
|
||||||
|
policy = policy or ToolPolicy()
|
||||||
|
return replace(self, denied=self.denied | frozenset(
|
||||||
|
canonical_tool(n) for n in set(disabled_tools or ()) | policy.all_disabled_names()),
|
||||||
|
block_all=self.block_all or policy.block_all_tool_calls,
|
||||||
|
disable_mcp=self.disable_mcp or policy.disable_mcp)
|
||||||
|
|
||||||
|
def intersect(self, child):
|
||||||
|
if not isinstance(child, RequestAuthority):
|
||||||
|
raise TypeError("Child authority must be server-owned RequestAuthority")
|
||||||
|
grants = []
|
||||||
|
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||||
|
theirs = {g.tool: g for g in child.grants}
|
||||||
|
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||||
|
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||||
|
block_all=self.block_all or child.block_all,
|
||||||
|
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True)
|
||||||
|
|
||||||
|
def continuation(self, *, owner=None, session_id=None):
|
||||||
|
"""A server continuation may rebind a session, never change owner/grants."""
|
||||||
|
if self.owner != _owner(owner):
|
||||||
|
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||||
|
return replace(self, session_id=str(session_id or ""), inherited=True)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"version": 1, "request_id": self.request_id, "owner": self.owner,
|
||||||
|
"session_id": self.session_id, "workspace": self.workspace,
|
||||||
|
"grants": [{"tool": g.tool,
|
||||||
|
"actions": None if g.actions is None else sorted(g.actions),
|
||||||
|
"inputs": None if g.inputs is None else sorted(g.inputs)} for g in self.grants],
|
||||||
|
"denied": sorted(self.denied), "block_all": self.block_all,
|
||||||
|
"disable_mcp": self.disable_mcp, "inherited": self.inherited}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value):
|
||||||
|
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||||
|
or value["version"] != 1):
|
||||||
|
raise ValueError("Unsupported authority snapshot")
|
||||||
|
def limits(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
||||||
|
raise ValueError("Malformed authority limits")
|
||||||
|
return frozenset(value)
|
||||||
|
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
||||||
|
tuple(OperationGrant(g["tool"], limits(g["actions"]), limits(g["inputs"]))
|
||||||
|
for g in value["grants"]), limits(value["denied"]),
|
||||||
|
value["block_all"], value["disable_mcp"], value["inherited"])
|
||||||
|
|
||||||
|
|
||||||
|
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||||
|
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SemanticIntent:
|
||||||
|
"""Routing facts, with no execution permission or provider inventory."""
|
||||||
|
capabilities: frozenset[str]
|
||||||
|
selected_tools: frozenset[str] | None
|
||||||
|
required_read: RequiredReadOperation | None
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_request(request_text, *, history=(), workspace=None, active_document=False,
|
||||||
|
image_attachment=False):
|
||||||
|
if not isinstance(request_text, str):
|
||||||
|
raise TypeError("Intent requires request text")
|
||||||
|
# Routing may use model/tool history. Admission may only inherit intent
|
||||||
|
# from trusted user requests; a model's proposal or attempted tool call
|
||||||
|
# cannot establish a new authorized operation class.
|
||||||
|
history = tuple(history or ())
|
||||||
|
trusted_history = []
|
||||||
|
for row in history:
|
||||||
|
get = row.get if isinstance(row, dict) else lambda key, default=None: getattr(row, key, default)
|
||||||
|
metadata = get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
except ValueError:
|
||||||
|
metadata = {}
|
||||||
|
if (get("role") == "user" and isinstance(metadata, dict)
|
||||||
|
and metadata.get("trusted") is not False and not metadata.get("tool_gate_untrusted")):
|
||||||
|
trusted_history.append({"role": "user", "content": get("content", "")})
|
||||||
|
families = requested_capabilities(request_text, trusted_history,
|
||||||
|
active_document=active_document, workspace=bool(workspace), image_attachment=image_attachment)
|
||||||
|
selected = selected_tools_for_request(request_text)
|
||||||
|
if (families <= {"unknown"} and selected is None
|
||||||
|
and re.search(r"\b(?:lan|local\s+(?:network|ip)|tailscale|arp|ip\s+route|default\s+route|subnet|network\s+interface|neighbor\s+table|wifi|ethernet)\b", request_text, re.I)
|
||||||
|
and re.search(r"\b(?:find|check|inspect|show|list|lookup|locate)\b", request_text, re.I)
|
||||||
|
and not re.search(r"\b(?:web|internet|online)\b", request_text, re.I)):
|
||||||
|
# An explicit local-network lookup is a host operation. The existing
|
||||||
|
# router already chooses host_shell; neither its schema nor bridge
|
||||||
|
# availability grants Bash/Python alongside this request.
|
||||||
|
return SemanticIntent(frozenset({"shell_files"}), frozenset({"host_shell"}), None)
|
||||||
|
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||||
|
|
||||||
|
|
||||||
|
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||||
|
history=(), policy=None, active_document=False,
|
||||||
|
image_attachment=False, capabilities=None):
|
||||||
|
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||||
|
if not isinstance(request_text, str):
|
||||||
|
raise TypeError("Authority requires trusted request text")
|
||||||
|
intent = interpret_request(request_text, history=history, active_document=active_document,
|
||||||
|
workspace=workspace, image_attachment=image_attachment)
|
||||||
|
families = intent.capabilities
|
||||||
|
if capabilities is not None:
|
||||||
|
families |= frozenset(capabilities)
|
||||||
|
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||||
|
selected = intent.selected_tools
|
||||||
|
if selected is not None:
|
||||||
|
tools = tools & set(selected) if families else set(selected)
|
||||||
|
if tools & {"web_search", "web_fetch"}:
|
||||||
|
tools.add("private_browser")
|
||||||
|
operation = intent.required_read
|
||||||
|
if operation is not None and canonical_tool(operation.tool) in {canonical_tool(n) for n in tools}:
|
||||||
|
tools = {canonical_tool(operation.tool)}
|
||||||
|
else:
|
||||||
|
operation = None
|
||||||
|
grants = []
|
||||||
|
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||||
|
name = canonical_tool(name)
|
||||||
|
actions = inputs = None
|
||||||
|
if name == "private_browser":
|
||||||
|
actions = _BROWSER_READ_ACTIONS
|
||||||
|
# Explicit interaction intent admits its operation class. A
|
||||||
|
# browser offered only as static-Web fallback gets no such grant.
|
||||||
|
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||||
|
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||||
|
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||||
|
if operation is not None and name == canonical_tool(operation.tool):
|
||||||
|
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||||
|
grants.append(OperationGrant(name, actions, inputs))
|
||||||
|
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||||
|
str(workspace or ""), tuple(grants))
|
||||||
|
return authority.restrict(policy or build_effective_tool_policy(last_user_message=request_text))
|
||||||
|
|
||||||
|
|
||||||
|
_ACTIVE: ContextVar[RequestAuthority | None] = ContextVar("request_authority", default=None)
|
||||||
|
MISSING_AUTHORITY = object()
|
||||||
|
|
||||||
|
|
||||||
|
def active_request_authority():
|
||||||
|
return _ACTIVE.get()
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal_tool_request(request):
|
||||||
|
"""HTTP authentication/owner attribution does not make a tool payload user intent."""
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
return (request.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN
|
||||||
|
or getattr(request.state, "current_user", None) == "internal-tool")
|
||||||
|
|
||||||
|
|
||||||
|
def require_user_approval_request(request):
|
||||||
|
if is_internal_tool_request(request):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(403, "Tool requests cannot submit user approval decisions.")
|
||||||
|
|
||||||
|
|
||||||
|
def request_authority_for_http(request, request_text, **context):
|
||||||
|
"""Known tool loopback is a continuation, never a fresh user grant source."""
|
||||||
|
if is_internal_tool_request(request):
|
||||||
|
return RequestAuthority.empty(owner=context.get("owner"),
|
||||||
|
session_id=context.get("session_id"), workspace=context.get("workspace")).restrict(context.get("policy"))
|
||||||
|
return create_request_authority(request_text, **context)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def bind_request_authority(authority):
|
||||||
|
if not isinstance(authority, RequestAuthority):
|
||||||
|
raise TypeError("Authority must be server-owned RequestAuthority")
|
||||||
|
parent = _ACTIVE.get()
|
||||||
|
authority = parent.intersect(authority) if parent is not None else authority
|
||||||
|
token = _ACTIVE.set(authority)
|
||||||
|
try:
|
||||||
|
yield authority
|
||||||
|
finally:
|
||||||
|
_ACTIVE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_text(messages):
|
||||||
|
for message in reversed(messages or ()):
|
||||||
|
metadata = message.get("metadata") or {}
|
||||||
|
if (message.get("role") != "user" or metadata.get("trusted") is False
|
||||||
|
or metadata.get("tool_gate_untrusted")):
|
||||||
|
continue
|
||||||
|
content = message.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "\n".join(p.get("text", "") for p in content
|
||||||
|
if isinstance(p, dict) and p.get("type") == "text")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def with_request_authority(func):
|
||||||
|
"""Bind once per invocation; model rounds/fallbacks never recreate grants."""
|
||||||
|
call_signature = signature(func)
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapped(*args, **kwargs):
|
||||||
|
bound = call_signature.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
parameters = bound.arguments
|
||||||
|
parent = active_request_authority()
|
||||||
|
authority = parameters.get("request_authority", MISSING_AUTHORITY)
|
||||||
|
if authority is MISSING_AUTHORITY:
|
||||||
|
approval = parameters.get("exact_approval")
|
||||||
|
if parent is not None:
|
||||||
|
authority = parent
|
||||||
|
elif approval is not None:
|
||||||
|
authority = approval.pending.request_authority or RequestAuthority.empty(
|
||||||
|
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||||
|
workspace=parameters.get("workspace"))
|
||||||
|
elif (parameters.get("_parent_run_id") or parameters.get("_is_teacher_run")
|
||||||
|
or parameters.get("workload") == "background"):
|
||||||
|
authority = RequestAuthority.empty(owner=parameters.get("owner"),
|
||||||
|
session_id=parameters.get("session_id"), workspace=parameters.get("workspace"))
|
||||||
|
else:
|
||||||
|
authority = create_request_authority(_request_text(parameters.get("messages")),
|
||||||
|
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||||
|
workspace=parameters.get("workspace"),
|
||||||
|
history=getattr(parameters.get("history_session"), "history", ()) or (),
|
||||||
|
active_document=bool(parameters.get("active_document")))
|
||||||
|
if not isinstance(authority, RequestAuthority):
|
||||||
|
raise TypeError("Missing or malformed server request authority")
|
||||||
|
if parent is None and parameters.get("exact_approval") is not None:
|
||||||
|
authority = replace(authority, inherited=False)
|
||||||
|
authority = authority.restrict(parameters.get("tool_policy"), parameters.get("disabled_tools"))
|
||||||
|
with bind_request_authority(authority) as effective:
|
||||||
|
if "request_authority" in parameters:
|
||||||
|
parameters["request_authority"] = effective
|
||||||
|
async with aclosing(func(*bound.args, **bound.kwargs)) as stream:
|
||||||
|
async for chunk in stream:
|
||||||
|
yield chunk
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def task_operation(task_type, action, prompt):
|
||||||
|
if task_type == "action":
|
||||||
|
tool = ("bash" if action in {"run_local", "run_script", "ssh_command"}
|
||||||
|
else "serve_model" if action == "cookbook_serve" else "scheduled__" + str(action))
|
||||||
|
return ExactOperation.normalize(tool, str(prompt or ""))
|
||||||
|
if task_type == "research":
|
||||||
|
return ExactOperation.normalize("trigger_research", str(prompt or ""))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authority=MISSING_AUTHORITY):
|
||||||
|
"""Only direct ingress grants; a model-created task is capped by its parent."""
|
||||||
|
operation = task_operation(task_type, action, prompt)
|
||||||
|
authority = create_request_authority(str(prompt or ""), owner=owner)
|
||||||
|
if operation is not None:
|
||||||
|
authority = replace(authority, grants=(OperationGrant(operation.tool,
|
||||||
|
inputs=frozenset({operation.input})),))
|
||||||
|
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
|
||||||
|
if parent_authority is None:
|
||||||
|
parent = RequestAuthority.empty(owner=owner)
|
||||||
|
if parent is not None:
|
||||||
|
authority = parent.intersect(replace(authority, session_id=parent.session_id,
|
||||||
|
workspace=parent.workspace))
|
||||||
|
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, session_id=None):
|
||||||
|
try:
|
||||||
|
value = json.loads(snapshot)
|
||||||
|
if value["task_input"] != [prompt, task_type, action]:
|
||||||
|
raise ValueError("Scheduled request changed")
|
||||||
|
return RequestAuthority.from_dict(value["authority"]).continuation(owner=owner, session_id=session_id)
|
||||||
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _background_path(job_id):
|
||||||
|
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id):
|
||||||
|
raise ValueError("Invalid background authority identity")
|
||||||
|
from src.constants import BG_JOBS_DIR
|
||||||
|
return Path(BG_JOBS_DIR) / (job_id + ".authority.json")
|
||||||
|
|
||||||
|
|
||||||
|
def save_background_authority(job_id, authority):
|
||||||
|
from core.atomic_io import atomic_write_json
|
||||||
|
atomic_write_json(_background_path(job_id), authority.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def restore_background_authority(job_id, *, owner=None, session_id=None):
|
||||||
|
try:
|
||||||
|
authority = RequestAuthority.from_dict(json.loads(_background_path(job_id).read_text()))
|
||||||
|
if authority.session_id != str(session_id or ""):
|
||||||
|
raise ValueError("Background session changed")
|
||||||
|
return authority.continuation(owner=owner, session_id=session_id)
|
||||||
|
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||||
+11
-2
@@ -36,11 +36,12 @@ def _background_result_message(rec):
|
|||||||
return untrusted_context_message("background job output", inject)
|
return untrusted_context_message("background job output", inject)
|
||||||
|
|
||||||
|
|
||||||
async def _drain_agent(sess, messages):
|
async def _drain_agent(sess, messages, request_authority=None):
|
||||||
"""Run the agent loop headless against a session. Returns
|
"""Run the agent loop headless against a session. Returns
|
||||||
(final_prose, tool_events) — tool_events in the same shape the live chat
|
(final_prose, tool_events) — tool_events in the same shape the live chat
|
||||||
saves, so the frontend rebuilds them as standard agent-thread tool cards."""
|
saves, so the frontend rebuilds them as standard agent-thread tool cards."""
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
full = ""
|
full = ""
|
||||||
final_replaced = False
|
final_replaced = False
|
||||||
tool_events = []
|
tool_events = []
|
||||||
@@ -52,6 +53,9 @@ async def _drain_agent(sess, messages):
|
|||||||
session_id=sess.id,
|
session_id=sess.id,
|
||||||
max_rounds=_FOLLOWUP_MAX_ROUNDS,
|
max_rounds=_FOLLOWUP_MAX_ROUNDS,
|
||||||
owner=getattr(sess, "owner", None),
|
owner=getattr(sess, "owner", None),
|
||||||
|
workspace=request_authority.workspace or None if request_authority is not None else None,
|
||||||
|
request_authority=(request_authority or RequestAuthority.empty(
|
||||||
|
owner=getattr(sess, "owner", None), session_id=sess.id)),
|
||||||
):
|
):
|
||||||
if not chunk.startswith("data: "):
|
if not chunk.startswith("data: "):
|
||||||
continue
|
continue
|
||||||
@@ -132,7 +136,12 @@ async def _run_followup(rec: dict) -> bool:
|
|||||||
context = sess.get_context_messages()
|
context = sess.get_context_messages()
|
||||||
context.append(_background_result_message(rec))
|
context.append(_background_result_message(rec))
|
||||||
|
|
||||||
full, tool_events = await _drain_agent(sess, context)
|
from src.agent_runtime.authority import restore_background_authority
|
||||||
|
from src.settings import get_setting
|
||||||
|
authority = restore_background_authority(
|
||||||
|
rec["id"], owner=getattr(sess, "owner", None), session_id=sess.id)
|
||||||
|
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||||
|
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
|
||||||
|
|
||||||
# Persist ONLY the assistant continuation so it renders as a normal agent
|
# Persist ONLY the assistant continuation so it renders as a normal agent
|
||||||
# turn — a standard chat bubble plus `tool_events` that the frontend
|
# turn — a standard chat bubble plus `tool_events` that the frontend
|
||||||
|
|||||||
@@ -4984,6 +4984,10 @@ async def preview_lines_until_finish(response, finish_event=None):
|
|||||||
await asyncio.gather(finish_task, return_exceptions=True)
|
await asyncio.gather(finish_task, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
||||||
|
|
||||||
|
|
||||||
|
@with_request_authority
|
||||||
async def stream_preview(*, endpoint_url, model, messages, headers, turn_contract,
|
async def stream_preview(*, endpoint_url, model, messages, headers, turn_contract,
|
||||||
session_id, owner, disabled_tools, tool_policy,
|
session_id, owner, disabled_tools, tool_policy,
|
||||||
history_session=None, external_untrusted_context_seen=False,
|
history_session=None, external_untrusted_context_seen=False,
|
||||||
@@ -4991,7 +4995,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
client_runtime_context=None, max_tokens=768, max_rounds=8,
|
client_runtime_context=None, max_tokens=768, max_rounds=8,
|
||||||
max_tool_calls=0,
|
max_tool_calls=0,
|
||||||
external_tool_schemas=None, temperature=0.0,
|
external_tool_schemas=None, temperature=0.0,
|
||||||
**ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
from src.generation_sampling import validate_temperature
|
from src.generation_sampling import validate_temperature
|
||||||
temperature = validate_temperature(temperature)
|
temperature = validate_temperature(temperature)
|
||||||
# This path sends requests directly with httpx and therefore bypasses
|
# This path sends requests directly with httpx and therefore bypasses
|
||||||
|
|||||||
+32
-2
@@ -1316,6 +1316,17 @@ class TaskScheduler:
|
|||||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||||
"""Execute a built-in action (no LLM needed)."""
|
"""Execute a built-in action (no LLM needed)."""
|
||||||
from src.builtin_actions import BUILTIN_ACTIONS
|
from src.builtin_actions import BUILTIN_ACTIONS
|
||||||
|
from src.agent_runtime.authority import (
|
||||||
|
bind_request_authority, restore_task_authority, task_operation,
|
||||||
|
)
|
||||||
|
authority = restore_task_authority(
|
||||||
|
getattr(task, "request_authority_json", None), task.prompt, task.task_type,
|
||||||
|
task.action, owner=task.owner)
|
||||||
|
from src.settings import get_setting
|
||||||
|
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||||
|
operation = task_operation(task.task_type, task.action, task.prompt)
|
||||||
|
if operation is None or not authority.permits(operation):
|
||||||
|
return "Scheduled action has no matching server request authority.", False
|
||||||
|
|
||||||
action_fn = BUILTIN_ACTIONS.get(task.action)
|
action_fn = BUILTIN_ACTIONS.get(task.action)
|
||||||
if not action_fn:
|
if not action_fn:
|
||||||
@@ -1342,7 +1353,8 @@ class TaskScheduler:
|
|||||||
if getattr(task, "model", None):
|
if getattr(task, "model", None):
|
||||||
kwargs["model"] = task.model
|
kwargs["model"] = task.model
|
||||||
kwargs["endpoint_url"] = getattr(task, "endpoint_url", None)
|
kwargs["endpoint_url"] = getattr(task, "endpoint_url", None)
|
||||||
result, success = await action_fn(**kwargs)
|
with bind_request_authority(authority):
|
||||||
|
result, success = await action_fn(**kwargs)
|
||||||
if getattr(task, "model", None):
|
if getattr(task, "model", None):
|
||||||
self._last_run_model = task.model
|
self._last_run_model = task.model
|
||||||
return result, success
|
return result, success
|
||||||
@@ -1945,6 +1957,7 @@ class TaskScheduler:
|
|||||||
datetime_context_msg: dict | None = None) -> str:
|
datetime_context_msg: dict | None = None) -> str:
|
||||||
"""Run the full agent loop with tool access, collecting the final text."""
|
"""Run the full agent loop with tool access, collecting the final text."""
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
|
from src.agent_runtime.authority import restore_task_authority
|
||||||
|
|
||||||
system_content = system_prompt or "You are a helpful assistant executing a scheduled task. Use available tools to complete the task thoroughly."
|
system_content = system_prompt or "You are a helpful assistant executing a scheduled task. Use available tools to complete the task thoroughly."
|
||||||
user_content = override_user_message or task.prompt
|
user_content = override_user_message or task.prompt
|
||||||
@@ -2000,6 +2013,10 @@ class TaskScheduler:
|
|||||||
_task_fallbacks = []
|
_task_fallbacks = []
|
||||||
# Close the stream in this task on every exit, including the
|
# Close the stream in this task on every exit, including the
|
||||||
# approval-pause break, so the agent run's context state unwinds here.
|
# approval-pause break, so the agent run's context state unwinds here.
|
||||||
|
request_authority = restore_task_authority(
|
||||||
|
getattr(task, "request_authority_json", None), task.prompt,
|
||||||
|
getattr(task, "task_type", "llm"), getattr(task, "action", None),
|
||||||
|
owner=task.owner, session_id=session_id)
|
||||||
async with contextlib.aclosing(stream_agent_loop(
|
async with contextlib.aclosing(stream_agent_loop(
|
||||||
endpoint_url=endpoint_url,
|
endpoint_url=endpoint_url,
|
||||||
model=model,
|
model=model,
|
||||||
@@ -2007,11 +2024,13 @@ class TaskScheduler:
|
|||||||
max_rounds=_task_max_rounds,
|
max_rounds=_task_max_rounds,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
owner=task.owner,
|
owner=task.owner,
|
||||||
|
workspace=request_authority.workspace or None,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
disabled_tools=disabled_tools,
|
disabled_tools=disabled_tools,
|
||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
fallbacks=_task_fallbacks,
|
fallbacks=_task_fallbacks,
|
||||||
workload="background",
|
workload="background",
|
||||||
|
request_authority=request_authority,
|
||||||
)) as agent_stream:
|
)) as agent_stream:
|
||||||
async for event_str in agent_stream:
|
async for event_str in agent_stream:
|
||||||
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
||||||
@@ -2109,6 +2128,14 @@ class TaskScheduler:
|
|||||||
|
|
||||||
async def _execute_research_task(self, task, db) -> str:
|
async def _execute_research_task(self, task, db) -> str:
|
||||||
"""Execute a deep research task using DeepResearcher."""
|
"""Execute a deep research task using DeepResearcher."""
|
||||||
|
from src.agent_runtime.authority import bind_request_authority, restore_task_authority, task_operation
|
||||||
|
from src.settings import get_setting
|
||||||
|
authority = restore_task_authority(
|
||||||
|
getattr(task, "request_authority_json", None), task.prompt, task.task_type,
|
||||||
|
getattr(task, "action", None), owner=task.owner)
|
||||||
|
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||||
|
if not authority.permits(task_operation(task.task_type, getattr(task, "action", None), task.prompt)):
|
||||||
|
raise PermissionError("Scheduled research has no matching server request authority.")
|
||||||
from core.database import Session as DbSession, ChatMessage
|
from core.database import Session as DbSession, ChatMessage
|
||||||
from src.deep_research import DeepResearcher
|
from src.deep_research import DeepResearcher
|
||||||
from src.research_handler import RESEARCH_DATA_DIR, ResearchHandler
|
from src.research_handler import RESEARCH_DATA_DIR, ResearchHandler
|
||||||
@@ -2180,7 +2207,8 @@ class TaskScheduler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
started_ts = time.time()
|
started_ts = time.time()
|
||||||
report = await researcher.research(task.prompt)
|
with bind_request_authority(authority):
|
||||||
|
report = await researcher.research(task.prompt)
|
||||||
completed_ts = time.time()
|
completed_ts = time.time()
|
||||||
try:
|
try:
|
||||||
stats = researcher.get_stats() or {}
|
stats = researcher.get_stats() or {}
|
||||||
@@ -2603,6 +2631,7 @@ class TaskScheduler:
|
|||||||
if (task.output_target or "session") == "session":
|
if (task.output_target or "session") == "session":
|
||||||
task.output_target = defs.get("output_target", "none")
|
task.output_target = defs.get("output_target", "none")
|
||||||
seeded = []
|
seeded = []
|
||||||
|
from src.agent_runtime.authority import seal_task_authority
|
||||||
for action, defs in HOUSEKEEPING_DEFAULTS.items():
|
for action, defs in HOUSEKEEPING_DEFAULTS.items():
|
||||||
if action in existing_actions:
|
if action in existing_actions:
|
||||||
continue
|
continue
|
||||||
@@ -2620,6 +2649,7 @@ class TaskScheduler:
|
|||||||
name=defs["name"],
|
name=defs["name"],
|
||||||
task_type="action",
|
task_type="action",
|
||||||
action=action,
|
action=action,
|
||||||
|
request_authority_json=seal_task_authority(None, "action", action, owner=owner),
|
||||||
trigger_type=trigger_type,
|
trigger_type=trigger_type,
|
||||||
trigger_event=defs.get("trigger_event"),
|
trigger_event=defs.get("trigger_event"),
|
||||||
trigger_count=defs.get("trigger_count"),
|
trigger_count=defs.get("trigger_count"),
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ async def run_teacher_inline(
|
|||||||
external_untrusted_context_seen: bool = False,
|
external_untrusted_context_seen: bool = False,
|
||||||
client_runtime_context: Optional[Dict[str, Any]] = None,
|
client_runtime_context: Optional[Dict[str, Any]] = None,
|
||||||
plan_mode: bool = False,
|
plan_mode: bool = False,
|
||||||
|
request_authority=None,
|
||||||
):
|
):
|
||||||
"""Async generator. Yields SSE event strings.
|
"""Async generator. Yields SSE event strings.
|
||||||
|
|
||||||
@@ -700,6 +701,7 @@ async def run_teacher_inline(
|
|||||||
active_email=active_email,
|
active_email=active_email,
|
||||||
turn_contract=turn_contract,
|
turn_contract=turn_contract,
|
||||||
_parent_run_id=parent_run_id,
|
_parent_run_id=parent_run_id,
|
||||||
|
request_authority=request_authority,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
client_runtime_context=deepcopy(client_runtime_context),
|
client_runtime_context=deepcopy(client_runtime_context),
|
||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
@@ -822,6 +824,7 @@ async def run_teacher_inline(
|
|||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
external_untrusted_context_seen=True,
|
external_untrusted_context_seen=True,
|
||||||
capabilities=capabilities_for_action("manage_skills", skill_content),
|
capabilities=capabilities_for_action("manage_skills", skill_content),
|
||||||
|
request_authority=request_authority,
|
||||||
)
|
)
|
||||||
approval = pending.public_payload(
|
approval = pending.public_payload(
|
||||||
reason=(
|
reason=(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from src.tool_approval_scopes import (
|
|||||||
scope_for_decision,
|
scope_for_decision,
|
||||||
)
|
)
|
||||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||||
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||||
@@ -117,6 +118,7 @@ def _binding_payload(
|
|||||||
continuation_query: Any,
|
continuation_query: Any,
|
||||||
effects: tuple[str, ...],
|
effects: tuple[str, ...],
|
||||||
result_integrity: str,
|
result_integrity: str,
|
||||||
|
request_authority: RequestAuthority | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"owner": _normalized_owner(owner),
|
"owner": _normalized_owner(owner),
|
||||||
@@ -137,6 +139,7 @@ def _binding_payload(
|
|||||||
"continuation_query": _normalized_continuation_query(continuation_query),
|
"continuation_query": _normalized_continuation_query(continuation_query),
|
||||||
"effects": list(effects),
|
"effects": list(effects),
|
||||||
"result_integrity": str(result_integrity),
|
"result_integrity": str(result_integrity),
|
||||||
|
"request_authority": request_authority.to_dict() if request_authority is not None else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +168,7 @@ class PendingToolApproval:
|
|||||||
# The originating user request is internal continuation context only; it
|
# The originating user request is internal continuation context only; it
|
||||||
# is never displayed or treated as authorization for the sealed action.
|
# is never displayed or treated as authorization for the sealed action.
|
||||||
request_text: str = ""
|
request_text: str = ""
|
||||||
|
request_authority: RequestAuthority | None = None
|
||||||
|
|
||||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -273,6 +277,7 @@ class ExactToolApproval:
|
|||||||
continuation_query=self.pending.continuation_query,
|
continuation_query=self.pending.continuation_query,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
|
request_authority=self.pending.request_authority,
|
||||||
)
|
)
|
||||||
return _canonical_digest(expected) == self.pending.digest
|
return _canonical_digest(expected) == self.pending.digest
|
||||||
|
|
||||||
@@ -356,7 +361,10 @@ class ToolApprovalStore:
|
|||||||
external_untrusted_context_seen: bool,
|
external_untrusted_context_seen: bool,
|
||||||
capabilities: ToolCapabilities,
|
capabilities: ToolCapabilities,
|
||||||
request_text: Any = "",
|
request_text: Any = "",
|
||||||
|
request_authority: RequestAuthority | None = None,
|
||||||
) -> PendingToolApproval:
|
) -> PendingToolApproval:
|
||||||
|
if request_authority is not None and not isinstance(request_authority, RequestAuthority):
|
||||||
|
raise TypeError("Approval authority must be server-owned RequestAuthority")
|
||||||
now = time.time()
|
now = time.time()
|
||||||
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||||
result_integrity = capabilities.result_integrity.value
|
result_integrity = capabilities.result_integrity.value
|
||||||
@@ -375,6 +383,7 @@ class ToolApprovalStore:
|
|||||||
continuation_query=continuation_query,
|
continuation_query=continuation_query,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
|
request_authority=request_authority,
|
||||||
)
|
)
|
||||||
pending = PendingToolApproval(
|
pending = PendingToolApproval(
|
||||||
approval_id=secrets.token_urlsafe(32),
|
approval_id=secrets.token_urlsafe(32),
|
||||||
@@ -398,6 +407,7 @@ class ToolApprovalStore:
|
|||||||
selected_tools=tuple(payload["selected_tools"]),
|
selected_tools=tuple(payload["selected_tools"]),
|
||||||
continuation_query=payload["continuation_query"],
|
continuation_query=payload["continuation_query"],
|
||||||
request_text=str(request_text or ""),
|
request_text=str(request_text or ""),
|
||||||
|
request_authority=request_authority,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
|
|||||||
+69
-25
@@ -1219,6 +1219,7 @@ async def _direct_fallback(
|
|||||||
"client_runtime_context": client_runtime_context,
|
"client_runtime_context": client_runtime_context,
|
||||||
"disabled_tools": frozenset(disabled_tools or ()),
|
"disabled_tools": frozenset(disabled_tools or ()),
|
||||||
"tool_policy": tool_policy,
|
"tool_policy": tool_policy,
|
||||||
|
"request_authority": active_request_authority(),
|
||||||
}
|
}
|
||||||
|
|
||||||
from src.agent_tools import TOOL_HANDLERS
|
from src.agent_tools import TOOL_HANDLERS
|
||||||
@@ -1259,6 +1260,10 @@ async def _document_tool_dispatch(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action
|
from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action
|
||||||
|
from src.agent_runtime.authority import (
|
||||||
|
MISSING_AUTHORITY, ExactOperation, RequestAuthority, active_request_authority,
|
||||||
|
bind_request_authority, save_background_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@record_action
|
@record_action
|
||||||
@@ -1278,6 +1283,7 @@ async def execute_tool_block(
|
|||||||
exact_approval: Optional[ExactToolApproval] = None,
|
exact_approval: Optional[ExactToolApproval] = None,
|
||||||
active_document_id: Optional[str] = None,
|
active_document_id: Optional[str] = None,
|
||||||
client_runtime_context: Optional[Dict[str, Any]] = None,
|
client_runtime_context: Optional[Dict[str, Any]] = None,
|
||||||
|
request_authority=MISSING_AUTHORITY,
|
||||||
) -> Tuple[str, Dict]:
|
) -> Tuple[str, Dict]:
|
||||||
"""Execute a single tool block. Returns (description, result_dict).
|
"""Execute a single tool block. Returns (description, result_dict).
|
||||||
|
|
||||||
@@ -1299,6 +1305,40 @@ async def execute_tool_block(
|
|||||||
"NO_TOOL_SECURITY_CONTEXT"
|
"NO_TOOL_SECURITY_CONTEXT"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
authority = active_request_authority() if request_authority is MISSING_AUTHORITY else request_authority
|
||||||
|
parent = active_request_authority()
|
||||||
|
if isinstance(authority, RequestAuthority) and parent is not None and authority is not parent:
|
||||||
|
authority = parent.intersect(authority)
|
||||||
|
try:
|
||||||
|
operation = ExactOperation.normalize(getattr(block, "tool_type", None), getattr(block, "content", None))
|
||||||
|
valid = isinstance(authority, RequestAuthority) and authority.bound_to(
|
||||||
|
owner=owner, session_id=session_id, workspace=workspace)
|
||||||
|
if valid:
|
||||||
|
authority = authority.restrict(tool_policy, disabled_tools)
|
||||||
|
exact_admission = bool(
|
||||||
|
valid and not authority.inherited and not authority.restricted(operation)
|
||||||
|
and exact_approval is not None and exact_approval.matches(
|
||||||
|
owner=owner, session_id=session_id, workspace=workspace,
|
||||||
|
tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None)))
|
||||||
|
admitted = valid and (authority.permits(operation) or exact_admission)
|
||||||
|
except (ValueError, TypeError, AttributeError) as error:
|
||||||
|
return f"{getattr(block, 'tool_type', '')}: invalid arguments", {
|
||||||
|
"error": (f"Tool arguments are not valid JSON: {error}"
|
||||||
|
if isinstance(error, json.JSONDecodeError) else str(error)),
|
||||||
|
"exit_code": 1, "blocked": True,
|
||||||
|
"failure_kind": "request_authority_denied",
|
||||||
|
}
|
||||||
|
if not admitted:
|
||||||
|
reason = "The exact operation is outside server request authority."
|
||||||
|
if tool_policy and any(tool_policy.blocks(name) for name in email_tool_policy_names(getattr(block, "tool_type", ""))):
|
||||||
|
reason = f"Execution of tool '{getattr(block, 'tool_type', '')}' is forbade by the active tool policy."
|
||||||
|
elif isinstance(authority, RequestAuthority) and authority.restricted(operation):
|
||||||
|
reason = "The exact operation is disabled by user or server request authority policy."
|
||||||
|
return f"{getattr(block, 'tool_type', '')}: BLOCKED", {
|
||||||
|
"error": reason,
|
||||||
|
"exit_code": 1, "blocked": True, "failure_kind": "request_authority_denied",
|
||||||
|
}
|
||||||
|
|
||||||
from src.turn_contract import active_turn_contract
|
from src.turn_contract import active_turn_contract
|
||||||
contract = active_turn_contract()
|
contract = active_turn_contract()
|
||||||
if contract is not None and not contract.permits(getattr(block, "tool_type", "")):
|
if contract is not None and not contract.permits(getattr(block, "tool_type", "")):
|
||||||
@@ -1393,31 +1433,32 @@ async def execute_tool_block(
|
|||||||
|
|
||||||
token = _active_workspace.set(workspace or None)
|
token = _active_workspace.set(workspace or None)
|
||||||
try:
|
try:
|
||||||
output = await _execute_tool_block_impl(
|
with bind_request_authority(authority):
|
||||||
block,
|
output = await _execute_tool_block_impl(
|
||||||
session_id=session_id,
|
block,
|
||||||
disabled_tools=disabled_tools,
|
session_id=session_id,
|
||||||
owner=owner,
|
disabled_tools=disabled_tools,
|
||||||
progress_cb=progress_cb,
|
owner=owner,
|
||||||
tool_policy=tool_policy,
|
progress_cb=progress_cb,
|
||||||
approved_document_id=(
|
tool_policy=tool_policy,
|
||||||
exact_approval.pending.document_id
|
approved_document_id=(
|
||||||
if approval_claimed
|
exact_approval.pending.document_id
|
||||||
else None
|
if approval_claimed
|
||||||
),
|
else None
|
||||||
approved_document_version=(
|
),
|
||||||
exact_approval.pending.document_version
|
approved_document_version=(
|
||||||
if approval_claimed
|
exact_approval.pending.document_version
|
||||||
else None
|
if approval_claimed
|
||||||
),
|
else None
|
||||||
approved_document_digest=(
|
),
|
||||||
exact_approval.pending.document_digest
|
approved_document_digest=(
|
||||||
if approval_claimed
|
exact_approval.pending.document_digest
|
||||||
else None
|
if approval_claimed
|
||||||
),
|
else None
|
||||||
active_document_id=active_document_id,
|
),
|
||||||
client_runtime_context=client_runtime_context,
|
active_document_id=active_document_id,
|
||||||
)
|
client_runtime_context=client_runtime_context,
|
||||||
|
)
|
||||||
if isinstance(security_context, ToolRunSecurityContext):
|
if isinstance(security_context, ToolRunSecurityContext):
|
||||||
security_context.observe_tool_result(
|
security_context.observe_tool_result(
|
||||||
getattr(block, "tool_type", None),
|
getattr(block, "tool_type", None),
|
||||||
@@ -1628,6 +1669,9 @@ async def _execute_tool_block_impl(
|
|||||||
from src import bg_jobs
|
from src import bg_jobs
|
||||||
mark_dispatch()
|
mark_dispatch()
|
||||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=agent_cwd())
|
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=agent_cwd())
|
||||||
|
# Only this server launch may seal detached-job authority; a
|
||||||
|
# handler/bridge output carrying a job id is not a grant source.
|
||||||
|
save_background_authority(rec["id"], active_request_authority())
|
||||||
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||||
desc = f"bash (background): {short}"
|
desc = f"bash (background): {short}"
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
@@ -486,12 +486,15 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
# Guard each fallback with `or`: args.get("prompt", default) returns
|
# Guard each fallback with `or`: args.get("prompt", default) returns
|
||||||
# None when the key is present but null, and None[:50] raises.
|
# None when the key is present but null, and None[:50] raises.
|
||||||
name = args.get("name") or (args.get("prompt") or args.get("action_name") or "Task")[:50]
|
name = args.get("name") or (args.get("prompt") or args.get("action_name") or "Task")[:50]
|
||||||
|
from src.agent_runtime.authority import seal_task_authority
|
||||||
|
|
||||||
task = ScheduledTask(
|
task = ScheduledTask(
|
||||||
id=task_id,
|
id=task_id,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
name=name,
|
name=name,
|
||||||
prompt=args.get("prompt"),
|
prompt=args.get("prompt"),
|
||||||
|
request_authority_json=seal_task_authority(
|
||||||
|
args.get("prompt"), task_type, args.get("action_name"), owner=owner),
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
action=args.get("action_name"),
|
action=args.get("action_name"),
|
||||||
schedule=args.get("schedule", "daily") if trigger_type == "schedule" else None,
|
schedule=args.get("schedule", "daily") if trigger_type == "schedule" else None,
|
||||||
@@ -557,6 +560,10 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if args.get("action_name") is not None:
|
if args.get("action_name") is not None:
|
||||||
task.action = args["action_name"]
|
task.action = args["action_name"]
|
||||||
changed.append("action")
|
changed.append("action")
|
||||||
|
if any(args.get(field) is not None for field in ("prompt", "task_type", "action_name")):
|
||||||
|
from src.agent_runtime.authority import seal_task_authority
|
||||||
|
task.request_authority_json = seal_task_authority(
|
||||||
|
task.prompt, task.task_type, task.action, owner=owner)
|
||||||
if args.get("trigger_type") is not None:
|
if args.get("trigger_type") is not None:
|
||||||
task.trigger_type = args["trigger_type"]
|
task.trigger_type = args["trigger_type"]
|
||||||
changed.append("trigger_type")
|
changed.append("trigger_type")
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
from src.agent_runtime.journal import mark_dispatch, record_action
|
from src.agent_runtime.journal import mark_dispatch, record_action
|
||||||
|
|
||||||
|
|
||||||
|
def server_authorized_executor(executor):
|
||||||
|
"""Give standalone dispatcher fixtures their explicit server grants.
|
||||||
|
|
||||||
|
These existing suites exercise handlers, policy, confinement and approvals.
|
||||||
|
Their fixture grants cover the declared native tool registry, independently
|
||||||
|
of the proposed block. Request-authority denial tests use the raw dispatcher.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import signature
|
||||||
|
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||||
|
from src.tool_policy import known_tool_names
|
||||||
|
from src.turn_contract import canonical_tool
|
||||||
|
call_signature = signature(executor)
|
||||||
|
@wraps(executor)
|
||||||
|
async def execute(*args, **kwargs):
|
||||||
|
bound = call_signature.bind(*args, **kwargs)
|
||||||
|
parameters = bound.arguments
|
||||||
|
kwargs.setdefault("request_authority", RequestAuthority(
|
||||||
|
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
||||||
|
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
||||||
|
tuple(OperationGrant(name) for name in sorted(
|
||||||
|
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"})),
|
||||||
|
))
|
||||||
|
return await executor(*args, **kwargs)
|
||||||
|
return execute
|
||||||
|
|
||||||
|
|
||||||
def authoritative_executor(function):
|
def authoritative_executor(function):
|
||||||
@record_action
|
@record_action
|
||||||
async def execute(block, *args, **kwargs):
|
async def execute(block, *args, **kwargs):
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ def test_external_tool_images_are_threaded_as_multimodal_evidence():
|
|||||||
|
|
||||||
|
|
||||||
def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
||||||
|
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||||
bridge_calls = []
|
bridge_calls = []
|
||||||
round_no = 0
|
round_no = 0
|
||||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||||
@@ -367,6 +368,8 @@ def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
|||||||
[{"role": "user", "content": "Perform the declared operation."}],
|
[{"role": "user", "content": "Perform the declared operation."}],
|
||||||
max_rounds=2,
|
max_rounds=2,
|
||||||
owner="pewds",
|
owner="pewds",
|
||||||
|
request_authority=RequestAuthority("declared-fixture", "pewds", "", "",
|
||||||
|
(OperationGrant("inspect_state"),)),
|
||||||
relevant_tools={"inspect_state"},
|
relevant_tools={"inspect_state"},
|
||||||
forced_tools={"inspect_state"},
|
forced_tools={"inspect_state"},
|
||||||
fallbacks=[],
|
fallbacks=[],
|
||||||
@@ -389,6 +392,7 @@ def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monkeypatch):
|
def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monkeypatch):
|
||||||
|
from src.agent_runtime.authority import create_request_authority
|
||||||
bridge_calls = []
|
bridge_calls = []
|
||||||
round_no = 0
|
round_no = 0
|
||||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||||
@@ -435,6 +439,7 @@ def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monke
|
|||||||
[{"role": "user", "content": "Search email for Project Alpha."}],
|
[{"role": "user", "content": "Search email for Project Alpha."}],
|
||||||
max_rounds=2,
|
max_rounds=2,
|
||||||
owner="public-user",
|
owner="public-user",
|
||||||
|
request_authority=create_request_authority("Search email for Project Alpha.", owner="public-user"),
|
||||||
relevant_tools={"search_emails"},
|
relevant_tools={"search_emails"},
|
||||||
forced_tools={"search_emails"},
|
forced_tools={"search_emails"},
|
||||||
fallbacks=[],
|
fallbacks=[],
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from src.agent_tools import ToolBlock, TOOL_TAGS # noqa: E402 (import first to
|
|||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||||
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||||
from src.tool_security import is_public_blocked_tool
|
from src.tool_security import is_public_blocked_tool
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
def _run(content):
|
def _run(content):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from routes.chat_routes import _agent_turn_cwd
|
|||||||
import routes.chat_routes as chat_routes
|
import routes.chat_routes as chat_routes
|
||||||
from src import tool_execution as _te
|
from src import tool_execution as _te
|
||||||
from src.agent_loop import _is_explicit_local_network_request
|
from src.agent_loop import _is_explicit_local_network_request
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
# Hold module-object references (not just from-imported names): other test
|
# Hold module-object references (not just from-imported names): other test
|
||||||
# modules re-import src.tool_execution via sys.modules pops, so string-target
|
# modules re-import src.tool_execution via sys.modules pops, so string-target
|
||||||
@@ -28,7 +29,7 @@ _ROUTED_BRIDGE_TOOLS = _te._ROUTED_BRIDGE_TOOLS
|
|||||||
async def _execute_tool_block_for_unit_tests(*args, **kwargs):
|
async def _execute_tool_block_for_unit_tests(*args, **kwargs):
|
||||||
"""Use the explicit non-security-context test mode for dispatch tests."""
|
"""Use the explicit non-security-context test mode for dispatch tests."""
|
||||||
kwargs.setdefault("security_context", _te.NO_TOOL_SECURITY_CONTEXT)
|
kwargs.setdefault("security_context", _te.NO_TOOL_SECURITY_CONTEXT)
|
||||||
return await _te.execute_tool_block(*args, **kwargs)
|
return await server_authorized_executor(_te.execute_tool_block)(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
execute_tool_block = _execute_tool_block_for_unit_tests
|
execute_tool_block = _execute_tool_block_for_unit_tests
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
from src import tool_security
|
from src import tool_security
|
||||||
from src.tool_security import (
|
from src.tool_security import (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import src.tool_execution as tool_execution
|
import src.tool_execution as tool_execution
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
from src.tool_execution import (
|
from src.tool_execution import (
|
||||||
AgentExecutionBridge,
|
AgentExecutionBridge,
|
||||||
@@ -11,6 +12,9 @@ from src.tool_execution import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
class Block:
|
class Block:
|
||||||
tool_type = "host_shell"
|
tool_type = "host_shell"
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from collections import namedtuple
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
from tests.helpers.document_source import document_source
|
from tests.helpers.document_source import document_source
|
||||||
from tests.helpers.js_modules import email_library_paths
|
from tests.helpers.js_modules import email_library_paths
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from src import ai_interaction, tool_execution
|
from src import ai_interaction, tool_execution
|
||||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
execute = server_authorized_executor(tool_execution.execute_tool_block)
|
||||||
calls = []
|
calls = []
|
||||||
async def generate(content, **kwargs):
|
async def generate(content, **kwargs):
|
||||||
calls.append((content, kwargs))
|
calls.append((content, kwargs))
|
||||||
@@ -140,9 +142,9 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
|||||||
block = SimpleNamespace(tool_type='generate_image', content='{"prompt":"A city"}')
|
block = SimpleNamespace(tool_type='generate_image', content='{"prompt":"A city"}')
|
||||||
journal = ActionJournal()
|
journal = ActionJournal()
|
||||||
with bind_journal(journal):
|
with bind_journal(journal):
|
||||||
_, denied = await tool_execution.execute_tool_block(block, owner='pewds', session_id='fixture',
|
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||||
_, result = await tool_execution.execute_tool_block(block, owner='pewds', session_id='fixture',
|
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||||
assert denied['exit_code'] != 0
|
assert denied['exit_code'] != 0
|
||||||
assert journal.actions[0].execution_id is None
|
assert journal.actions[0].execution_id is None
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""Request grants are independent of tool offerings and model proposals."""
|
||||||
|
import asyncio
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from dataclasses import replace
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agent_runtime.authority import (
|
||||||
|
MISSING_AUTHORITY, ExactOperation, OperationGrant, RequestAuthority,
|
||||||
|
active_request_authority, bind_request_authority, create_request_authority,
|
||||||
|
restore_background_authority, restore_task_authority, save_background_authority,
|
||||||
|
seal_task_authority, task_operation, with_request_authority,
|
||||||
|
is_internal_tool_request, require_user_approval_request,
|
||||||
|
request_authority_for_http,
|
||||||
|
)
|
||||||
|
from src.tool_policy import ToolPolicy
|
||||||
|
from src.tool_types import ToolBlock
|
||||||
|
|
||||||
|
|
||||||
|
def authority(*tools, owner="alice", session_id="s", workspace=""):
|
||||||
|
return RequestAuthority("request-test", owner, session_id, workspace,
|
||||||
|
tuple(OperationGrant(tool) for tool in tools))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("offering", ["schema", "bridge", "dynamic"])
|
||||||
|
async def test_availability_and_model_selection_do_not_grant_execution(monkeypatch, offering):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
from src.turn_contract import bind_turn_contract, resolve_turn_contract
|
||||||
|
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
offered_handler = AsyncMock()
|
||||||
|
if offering == "dynamic":
|
||||||
|
import src.agent_tools
|
||||||
|
monkeypatch.setitem(src.agent_tools.TOOL_HANDLERS, "bash", offered_handler)
|
||||||
|
bridge_context = execution.bind_execution_bridge(execution.AgentExecutionBridge(
|
||||||
|
route_tool=offered_handler, supported_tools=frozenset({"bash"}))) if offering == "bridge" else nullcontext()
|
||||||
|
contract = resolve_turn_contract(capabilities={"shell_files"}, policy=ToolPolicy(),
|
||||||
|
schemas=[{"function": {"name": "bash"}}])
|
||||||
|
with bind_turn_contract(contract), bridge_context:
|
||||||
|
description, result = await execution.execute_tool_block(
|
||||||
|
ToolBlock("bash", "echo 'authorized by model'"), owner="alice", session_id="s",
|
||||||
|
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
|
request_authority=authority("transcribe_media"))
|
||||||
|
assert "BLOCKED" in description
|
||||||
|
assert result["failure_kind"] == "request_authority_denied"
|
||||||
|
implementation.assert_not_awaited()
|
||||||
|
offered_handler.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("user_text,allowed,denied", [
|
||||||
|
("Transcribe /workspace/input/audio.wav", "transcribe_media", "bash"),
|
||||||
|
("OCR extract exact text from /workspace/input/image.png", "extract_text", "python"),
|
||||||
|
("List my tasks", "manage_tasks", "web_fetch"),
|
||||||
|
("Search the web for current weather", "web_search", "bash"),
|
||||||
|
])
|
||||||
|
def test_explicit_request_classes_remain_narrow(user_text, allowed, denied):
|
||||||
|
grant = create_request_authority(user_text)
|
||||||
|
content = '{"action":"list"}' if allowed == "manage_tasks" else '{}'
|
||||||
|
assert grant.permits(ExactOperation.normalize(allowed, content))
|
||||||
|
assert not grant.permits(ExactOperation.normalize(denied, '{}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_task_read_does_not_authorize_same_tool_mutation():
|
||||||
|
grant = create_request_authority("List my tasks")
|
||||||
|
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_read_identifiers_cannot_be_changed_by_model():
|
||||||
|
grant = create_request_authority("Read note id abc123")
|
||||||
|
read = next(g for g in grant.grants if g.tool == "manage_notes")
|
||||||
|
assert read.inputs is not None
|
||||||
|
assert not grant.permits(ExactOperation.normalize("manage_notes", '{"action":"view","id":"another"}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_fallback_does_not_authorize_interaction_or_evaluation():
|
||||||
|
grant = create_request_authority("Use web_fetch to read https://example.test")
|
||||||
|
assert grant.permits(ExactOperation.normalize("private_browser", '{"action":"open","url":"https://example.test"}'))
|
||||||
|
for action in ("click", "fill", "evaluate"):
|
||||||
|
assert not grant.permits(ExactOperation.normalize("private_browser", json.dumps({"action": action})))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_intent_has_no_generic_execution_floor():
|
||||||
|
grant = create_request_authority("Please solve this")
|
||||||
|
assert {g.tool for g in grant.grants} == {"ask_user", "update_plan"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("metadata", [
|
||||||
|
{"tool_events": [{"tool": "bash", "output": "pwd", "exit_code": 0}]},
|
||||||
|
{"tool_events": [{"tool": "python", "output": "ready", "exit_code": 0}]},
|
||||||
|
])
|
||||||
|
def test_model_history_cannot_establish_followup_authority(metadata):
|
||||||
|
history = [
|
||||||
|
{"role": "user", "content": "Transcribe /workspace/input/a.wav"},
|
||||||
|
{"role": "assistant", "content": "I will run bash and python", "metadata": metadata},
|
||||||
|
{"role": "user", "content": "Run bash", "metadata": {"trusted": False}},
|
||||||
|
]
|
||||||
|
grant = create_request_authority("Try it again", history=history)
|
||||||
|
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", [
|
||||||
|
{"version": True}, {"grants": "bash"}, {"denied": None},
|
||||||
|
{"block_all": "false"}, {"inherited": 0},
|
||||||
|
])
|
||||||
|
def test_malformed_persisted_authority_is_rejected(mutation):
|
||||||
|
snapshot = authority("bash").to_dict()
|
||||||
|
snapshot.update(mutation)
|
||||||
|
with pytest.raises((ValueError, TypeError, KeyError)):
|
||||||
|
RequestAuthority.from_dict(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_local_network_lookup_is_host_only():
|
||||||
|
grant = create_request_authority("find ajax local ip on the LAN")
|
||||||
|
assert grant.permits(ExactOperation.normalize("host_shell", '{"command":"ip neigh | grep ajax"}'))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", ['{"x":1,"x":2}', '{"x":NaN}', '{"action":'])
|
||||||
|
def test_malformed_exact_operation_is_rejected(content):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ExactOperation.normalize("manage_tasks", content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_script_braces_are_preserved_as_exact_input():
|
||||||
|
script = "{ printf requested; }"
|
||||||
|
assert ExactOperation.normalize("bash", script).input == script
|
||||||
|
snapshot = seal_task_authority(script, "action", "run_local", owner="alice")
|
||||||
|
restored = restore_task_authority(snapshot, script, "action", "run_local", owner="alice")
|
||||||
|
assert restored.permits(task_operation("action", "run_local", script))
|
||||||
|
assert not restored.permits(task_operation("action", "run_local", "{ printf other; }"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalization_and_aliases_do_not_erase_denials():
|
||||||
|
grant = authority("read_email").restrict(disabled_tools={"mcp__email__read_email"})
|
||||||
|
assert not grant.permits(ExactOperation.normalize("read_email", '{}'))
|
||||||
|
grant = authority("read_email").restrict(ToolPolicy(disable_mcp=True))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("mcp__email__read_email", '{}'))
|
||||||
|
assert ExactOperation.normalize("manage_tasks", '{ "action": "list" }').input == '{"action":"list"}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("state", [MISSING_AUTHORITY, None, {}, "authorized"])
|
||||||
|
async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch, state):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
implementation = AsyncMock()
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||||
|
security_context=execution.NO_TOOL_SECURITY_CONTEXT, request_authority=state)
|
||||||
|
assert result["blocked"] is True
|
||||||
|
implementation.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
for disabled in (set(), {"bash"}):
|
||||||
|
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||||
|
owner="alice", session_id="s", disabled_tools=disabled,
|
||||||
|
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
|
request_authority=authority("bash"))
|
||||||
|
assert result["exit_code"] == (1 if disabled else 0)
|
||||||
|
assert implementation.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nested_stream_intersects_and_restores_parent_on_close():
|
||||||
|
seen = []
|
||||||
|
@with_request_authority
|
||||||
|
async def child(messages, request_authority=MISSING_AUTHORITY, owner="alice", session_id="s"):
|
||||||
|
seen.append(active_request_authority())
|
||||||
|
yield "child"
|
||||||
|
parent = authority("transcribe_media")
|
||||||
|
with bind_request_authority(parent):
|
||||||
|
stream = child([{"role": "user", "content": "Run bash"}],
|
||||||
|
request_authority=authority("bash", "transcribe_media"))
|
||||||
|
assert await anext(stream) == "child"
|
||||||
|
assert not seen[0].permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
assert seen[0].permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||||
|
await stream.aclose()
|
||||||
|
assert active_request_authority() is parent
|
||||||
|
assert active_request_authority() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retries_and_provider_changes_do_not_recreate_authority():
|
||||||
|
seen = []
|
||||||
|
@with_request_authority
|
||||||
|
async def run(messages, request_authority=MISSING_AUTHORITY, owner="alice", session_id="s"):
|
||||||
|
for proposed in ("transcribe_media", "bash", "python"):
|
||||||
|
seen.append(active_request_authority())
|
||||||
|
yield active_request_authority().permits(ExactOperation.normalize(proposed, '{}'))
|
||||||
|
assert [x async for x in run([{"role": "user", "content": "Transcribe /workspace/input/a.wav"}])] == [True, False, False]
|
||||||
|
assert all(value is seen[0] for value in seen)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_snapshot_caps_model_payload_and_rejects_changed_or_missing_state():
|
||||||
|
parent = authority("manage_tasks")
|
||||||
|
with bind_request_authority(parent):
|
||||||
|
snapshot = seal_task_authority("Run bash in the workspace", "llm", None, owner="alice")
|
||||||
|
restored = restore_task_authority(snapshot, "Run bash in the workspace", "llm", None, owner="alice")
|
||||||
|
assert not restored.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
for state in (None, "{}", snapshot):
|
||||||
|
changed = restore_task_authority(state, "a different prompt", "llm", None, owner="alice")
|
||||||
|
assert changed.grants == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_task_ingress_seals_only_exact_builtin_action():
|
||||||
|
snapshot = seal_task_authority("printf requested", "action", "run_local", owner="alice")
|
||||||
|
restored = restore_task_authority(snapshot, "printf requested", "action", "run_local", owner="alice")
|
||||||
|
assert restored.permits(task_operation("action", "run_local", "printf requested"))
|
||||||
|
assert not restored.permits(ExactOperation.normalize("bash", "printf other"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypatch, tmp_path):
|
||||||
|
import src.constants
|
||||||
|
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||||
|
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
|
||||||
|
save_background_authority("job1", grant)
|
||||||
|
restored = restore_background_authority("job1", owner="alice", session_id="s")
|
||||||
|
assert restored.request_id == grant.request_id
|
||||||
|
assert restored.denied == frozenset({"bash"})
|
||||||
|
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
||||||
|
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_server_background_launch_can_seal_job_authority(monkeypatch, tmp_path):
|
||||||
|
import src.constants
|
||||||
|
from src import bg_jobs, tool_execution as execution
|
||||||
|
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
|
||||||
|
monkeypatch.setattr(bg_jobs, "launch", lambda *a, **k: {"id": "server-job"})
|
||||||
|
await execution.execute_tool_block(ToolBlock("bash", "#!bg\nprintf trusted"),
|
||||||
|
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
|
request_authority=authority("bash"))
|
||||||
|
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
||||||
|
assert restored.request_id == "request-test"
|
||||||
|
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
|
||||||
|
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||||
|
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
|
||||||
|
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
|
request_authority=authority("transcribe_media"))
|
||||||
|
assert not (tmp_path / "forged-job.authority.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
from src.tool_approvals import ToolApprovalStore
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
original = authority("transcribe_media")
|
||||||
|
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
|
||||||
|
tool_name="bash", content="printf approved", workspace=None,
|
||||||
|
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
||||||
|
request_authority=original, selected_tools={"bash", "python"})
|
||||||
|
approval = store.consume(pending.approval_id, owner="alice", session_id="s", decision="approve_task")
|
||||||
|
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
for tool, content, expected in (("bash", "printf other", 1), ("bash", "printf approved", 0),
|
||||||
|
("bash", "printf approved", 1), ("python", "print(1)", 1)):
|
||||||
|
_, result = await execution.execute_tool_block(ToolBlock(tool, content), owner="alice", session_id="s",
|
||||||
|
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True),
|
||||||
|
request_authority=original, exact_approval=approval)
|
||||||
|
assert result["exit_code"] == expected
|
||||||
|
assert implementation.await_count == 1
|
||||||
|
assert "request_authority" not in pending.public_payload()
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_digest_binds_authority_snapshot():
|
||||||
|
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
|
||||||
|
from src.tool_capabilities import capabilities_for_action
|
||||||
|
pending = ToolApprovalStore().create(owner="alice", session_id="s", origin_run_id="journal",
|
||||||
|
tool_name="bash", content="pwd", workspace=None, external_untrusted_context_seen=True,
|
||||||
|
capabilities=capabilities_for_action("bash", "pwd"), request_authority=authority("transcribe_media"))
|
||||||
|
forged = ExactToolApproval(replace(pending, request_authority=authority("bash", "python")))
|
||||||
|
assert not forged.matches(owner="alice", session_id="s", workspace=None, tool_name="bash", content="pwd")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nested_approval_cannot_cross_parent_class_ceiling(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
from src.tool_approvals import ToolApprovalStore
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = store.create(owner="alice", session_id="s", origin_run_id="parent",
|
||||||
|
tool_name="bash", content="pwd", workspace=None, external_untrusted_context_seen=True,
|
||||||
|
capabilities=capabilities_for_action("bash", "pwd"))
|
||||||
|
approval = store.consume(pending.approval_id, owner="alice", session_id="s", decision="approve")
|
||||||
|
implementation = AsyncMock()
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
with bind_request_authority(authority("transcribe_media")):
|
||||||
|
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"), owner="alice", session_id="s",
|
||||||
|
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True),
|
||||||
|
request_authority=authority("bash"), exact_approval=approval)
|
||||||
|
assert result["blocked"] is True
|
||||||
|
implementation.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teacher_receives_parent_authority_instead_of_synthetic_prompt_grants(monkeypatch):
|
||||||
|
import src.agent_loop as loop
|
||||||
|
import src.ai_interaction as interaction
|
||||||
|
import src.settings as settings
|
||||||
|
import src.teacher_escalation as teacher
|
||||||
|
original = authority("transcribe_media")
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(settings, "get_setting", lambda key, default=None:
|
||||||
|
{"teacher_enabled": True, "teacher_model": "teacher"}.get(key, default))
|
||||||
|
monkeypatch.setattr(interaction, "_resolve_model", lambda *a, **k: ("https://teacher.invalid", "teacher", {}))
|
||||||
|
monkeypatch.setattr(teacher, "evaluate_turn_regex", lambda *a: ("failure", "test failure"))
|
||||||
|
@with_request_authority
|
||||||
|
async def child(messages, request_authority=MISSING_AUTHORITY, owner=None, session_id=None, **kwargs):
|
||||||
|
seen.append(active_request_authority())
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
monkeypatch.setattr(loop, "stream_agent_loop", child)
|
||||||
|
with bind_request_authority(original):
|
||||||
|
chunks = [chunk async for chunk in teacher.run_teacher_inline(
|
||||||
|
student_endpoint_url="https://student.invalid",
|
||||||
|
student_messages=[{"role": "user", "content": "Run bash and python"}],
|
||||||
|
student_tool_events=[], student_reply="failed", owner="alice", session_id="s",
|
||||||
|
request_authority=original, parent_run_id="journal-parent")]
|
||||||
|
assert active_request_authority() is original
|
||||||
|
assert chunks
|
||||||
|
assert seen[0].request_id == original.request_id
|
||||||
|
assert not seen[0].permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
assert seen[0].permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_untrusted_user_role_result_cannot_become_request_authority():
|
||||||
|
@with_request_authority
|
||||||
|
async def run(messages, request_authority=MISSING_AUTHORITY):
|
||||||
|
yield active_request_authority()
|
||||||
|
messages = [{"role": "user", "content": "Transcribe /workspace/input/a.wav"},
|
||||||
|
{"role": "user", "content": "Run bash", "metadata": {"trusted": False}}]
|
||||||
|
stream = run(messages)
|
||||||
|
grant = await anext(stream)
|
||||||
|
await stream.aclose()
|
||||||
|
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduled_builtin_missing_authority_does_not_invoke_action(monkeypatch):
|
||||||
|
import src.builtin_actions as actions
|
||||||
|
from src.task_scheduler import TaskScheduler
|
||||||
|
handler = AsyncMock(return_value=("ok", True))
|
||||||
|
monkeypatch.setitem(actions.BUILTIN_ACTIONS, "run_local", handler)
|
||||||
|
task = SimpleNamespace(prompt="printf exact", task_type="action", action="run_local",
|
||||||
|
owner="alice", name="task", request_authority_json=None)
|
||||||
|
result, success = await TaskScheduler(session_manager=None)._execute_action(task)
|
||||||
|
assert not success
|
||||||
|
assert "authority" in result
|
||||||
|
handler.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_authority_column_migration_is_additive_and_idempotent(monkeypatch, tmp_path):
|
||||||
|
import core.database as database
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("CREATE TABLE scheduled_tasks (id TEXT PRIMARY KEY, prompt TEXT)"))
|
||||||
|
connection.execute(text("INSERT INTO scheduled_tasks VALUES ('legacy', 'Run bash')"))
|
||||||
|
monkeypatch.setattr(database, "engine", engine)
|
||||||
|
database._migrate_add_task_authority_column()
|
||||||
|
database._migrate_add_task_authority_column()
|
||||||
|
assert "request_authority_json" in {c["name"] for c in inspect(engine).get_columns("scheduled_tasks")}
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert connection.execute(text("SELECT prompt, request_authority_json FROM scheduled_tasks")).one() == ("Run bash", None)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_server_seeded_defaults_have_authority_but_legacy_rows_do_not_gain_it(monkeypatch, tmp_path):
|
||||||
|
import core.database as database
|
||||||
|
import routes.prefs_routes as preferences
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from src.task_scheduler import TaskScheduler
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path / 'tasks.db'}")
|
||||||
|
database.Base.metadata.create_all(engine)
|
||||||
|
sessions = sessionmaker(bind=engine)
|
||||||
|
monkeypatch.setattr(database, "SessionLocal", sessions)
|
||||||
|
monkeypatch.setattr(preferences, "_load_for_user", lambda owner: {})
|
||||||
|
scheduler = TaskScheduler(session_manager=None)
|
||||||
|
monkeypatch.setattr(scheduler, "ensure_assistant_defaults", AsyncMock())
|
||||||
|
with sessions() as db:
|
||||||
|
db.add(database.ScheduledTask(id="legacy", owner="alice", name="Legacy housekeeping",
|
||||||
|
task_type="action", action="tidy_sessions"))
|
||||||
|
db.commit()
|
||||||
|
await scheduler.ensure_defaults("alice")
|
||||||
|
with sessions() as db:
|
||||||
|
tasks = db.query(database.ScheduledTask).all()
|
||||||
|
assert len(tasks) > 1
|
||||||
|
for task in tasks:
|
||||||
|
grant = restore_task_authority(task.request_authority_json, task.prompt,
|
||||||
|
task.task_type, task.action, owner=task.owner)
|
||||||
|
assert grant.permits(task_operation(task.task_type, task.action, task.prompt)) == (task.id != "legacy")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
seen = []
|
||||||
|
async def handler(*args, **kwargs):
|
||||||
|
seen.append(active_request_authority())
|
||||||
|
with bind_request_authority(authority("bash", "transcribe_media")) as child:
|
||||||
|
assert not child.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
return "transcribe_media", {"exit_code": 0}
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||||
|
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'), owner="alice", session_id="s",
|
||||||
|
security_context=execution.NO_TOOL_SECURITY_CONTEXT, request_authority=authority("transcribe_media"))
|
||||||
|
assert seen
|
||||||
|
assert active_request_authority() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_http_task_payload_is_not_new_user_authority():
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
from routes.task.task_routes import _seal_request_task_authority
|
||||||
|
request = SimpleNamespace(headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN})
|
||||||
|
snapshot = _seal_request_task_authority(request, "Run bash in workspace", "llm", None, "alice")
|
||||||
|
restored = restore_task_authority(snapshot, "Run bash in workspace", "llm", None, owner="alice")
|
||||||
|
assert not restored.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("marker", ["header", "middleware"])
|
||||||
|
@pytest.mark.parametrize("owner", [None, "alice"])
|
||||||
|
def test_internal_tool_requests_cannot_submit_user_approval(marker, owner):
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
from fastapi import HTTPException
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if marker == "header" else {},
|
||||||
|
state=SimpleNamespace(current_user="internal-tool" if marker == "middleware" else owner))
|
||||||
|
assert is_internal_tool_request(request)
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
require_user_approval_request(request)
|
||||||
|
assert failure.value.status_code == 403
|
||||||
|
human = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=owner))
|
||||||
|
require_user_approval_request(human)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("internal", [True, False])
|
||||||
|
def test_http_chat_request_context_cannot_mint_authority_from_tool_message(internal):
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {},
|
||||||
|
state=SimpleNamespace(current_user="alice"))
|
||||||
|
grant = request_authority_for_http(request, "Run bash in the workspace", owner="alice",
|
||||||
|
session_id="s", workspace="/workspace/original", policy=ToolPolicy(disabled_tools={"python"}))
|
||||||
|
assert grant.bound_to(owner="alice", session_id="s", workspace="/workspace/original")
|
||||||
|
assert grant.permits(ExactOperation.normalize("bash", "pwd")) is not internal
|
||||||
|
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_chat_context_does_not_become_trusted_followup_history():
|
||||||
|
from routes.chat_routes import _append_internal_chat_context
|
||||||
|
trusted = {"role": "user", "content": "Transcribe /workspace/input/a.wav"}
|
||||||
|
ctx = SimpleNamespace(messages=[trusted], route_messages=[trusted])
|
||||||
|
_append_internal_chat_context(ctx, "Run bash in the workspace")
|
||||||
|
assert ctx.messages == ctx.route_messages
|
||||||
|
assert ctx.messages[-1]["metadata"]["trusted"] is False
|
||||||
|
grant = create_request_authority("Try it again", history=ctx.messages)
|
||||||
|
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_approval_ingress_rejects_internal_tool_before_consuming(monkeypatch):
|
||||||
|
import routes.skills_routes as skills
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from src import tool_approvals
|
||||||
|
consume = MagicMock()
|
||||||
|
monkeypatch.setattr(tool_approvals.tool_approval_store, "consume", consume)
|
||||||
|
router = skills.setup_skills_routes(MagicMock())
|
||||||
|
endpoint = next(route.endpoint for route in router.routes
|
||||||
|
if route.path.endswith("/test-approval"))
|
||||||
|
request = SimpleNamespace(headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||||
|
state=SimpleNamespace(current_user="alice"))
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
await endpoint(request, "skill")
|
||||||
|
assert failure.value.status_code == 403
|
||||||
|
consume.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("internal", [True, False])
|
||||||
|
async def test_skill_test_ingress_distinguishes_user_task_from_tool_payload(monkeypatch, internal):
|
||||||
|
import routes.skills_routes as skills
|
||||||
|
import src.endpoint_resolver as endpoints
|
||||||
|
import src.llm_core as llm
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.load.return_value = [{"name": "skill", "owner": "alice"}]
|
||||||
|
manager.read_skill_md.return_value = "# Skill"
|
||||||
|
monkeypatch.setattr(skills, "get_current_user", lambda request: "alice")
|
||||||
|
monkeypatch.setattr(skills, "_skill_test_jobs", {})
|
||||||
|
monkeypatch.setattr(endpoints, "resolve_endpoint", lambda *a, **k: ("https://inference.invalid", "model", {}))
|
||||||
|
monkeypatch.setattr(llm, "list_model_ids", lambda *a, **k: [])
|
||||||
|
seen = []
|
||||||
|
async def run(*args, **kwargs):
|
||||||
|
seen.append(kwargs["request_authority"])
|
||||||
|
monkeypatch.setattr(skills, "_run_skill_test_job", run)
|
||||||
|
router = skills.setup_skills_routes(manager)
|
||||||
|
endpoint = next(route.endpoint for route in router.routes if route.path.endswith("/{skill_id}/test"))
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {},
|
||||||
|
state=SimpleNamespace(current_user="alice"),
|
||||||
|
json=AsyncMock(return_value={"task": "Run bash in the workspace"}))
|
||||||
|
result = await endpoint(request, "skill")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert result["status"] == "running"
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0].permits(ExactOperation.normalize("bash", "pwd")) is not internal
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduled_override_cannot_replace_stored_authority(monkeypatch):
|
||||||
|
from src import agent_loop
|
||||||
|
from src.task_scheduler import TaskScheduler
|
||||||
|
seen = []
|
||||||
|
async def fake_stream(*args, **kwargs):
|
||||||
|
seen.append(kwargs)
|
||||||
|
yield 'data: {"delta":"done"}\n\n'
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
monkeypatch.setattr(agent_loop, "stream_agent_loop", fake_stream)
|
||||||
|
task = SimpleNamespace(prompt="Transcribe /workspace/input/a.wav", task_type="llm", action=None,
|
||||||
|
owner="alice", name="test", max_steps=1)
|
||||||
|
with bind_request_authority(authority("transcribe_media", workspace="/workspace/original")):
|
||||||
|
task.request_authority_json = seal_task_authority(task.prompt, "llm", None, owner="alice")
|
||||||
|
await TaskScheduler(session_manager=None)._run_agent_loop("https://inference.invalid", "test", task, "s",
|
||||||
|
override_user_message="Run bash and python")
|
||||||
|
grant = seen[0]["request_authority"]
|
||||||
|
assert grant.permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||||
|
assert grant.workspace == "/workspace/original"
|
||||||
|
assert seen[0]["workspace"] == grant.workspace
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_retains_denial_before_offered_inventory_reconciliation(monkeypatch):
|
||||||
|
from src import agent_loop as loop, tool_execution as execution
|
||||||
|
from src.turn_contract import resolve_turn_contract
|
||||||
|
implementation = AsyncMock(return_value=("bash", {"exit_code": 0, "output": "unexpected"}))
|
||||||
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
|
monkeypatch.setattr(loop, "get_setting", lambda key, default=None: default)
|
||||||
|
monkeypatch.setattr(loop, "get_mcp_manager", lambda: None)
|
||||||
|
monkeypatch.setattr(loop, "estimate_tokens", lambda *a, **k: 10)
|
||||||
|
async def fake_stream(*args, **kwargs):
|
||||||
|
yield 'data: ' + json.dumps({"delta": '```bash\npwd\n```'}) + '\n\n'
|
||||||
|
monkeypatch.setattr(loop, "stream_llm_with_fallback", fake_stream)
|
||||||
|
contract = resolve_turn_contract(capabilities={"shell_files"}, selected_tools={"bash"},
|
||||||
|
schemas=[{"function": {"name": "bash"}}], policy=ToolPolicy())
|
||||||
|
chunks = [chunk async for chunk in loop.stream_agent_loop(
|
||||||
|
"https://inference.invalid", "test", [{"role": "user", "content": "Run bash pwd"}],
|
||||||
|
owner="alice", session_id="s", max_rounds=1, turn_contract=contract, disabled_tools={"bash"})]
|
||||||
|
implementation.assert_not_awaited()
|
||||||
|
assert chunks[-1] == 'data: [DONE]\n\n'
|
||||||
|
assert active_request_authority() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authority_denial_cannot_create_completion_receipt(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||||
|
journal = ActionJournal()
|
||||||
|
with bind_journal(journal):
|
||||||
|
await execution.execute_tool_block(ToolBlock("bash", "pwd"), owner="alice", session_id="s",
|
||||||
|
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
|
request_authority=authority("transcribe_media"))
|
||||||
|
assert len(journal.actions) == 1
|
||||||
|
assert journal.actions[0].execution_id is None
|
||||||
|
assert journal.actions[0].outcome["authoritative"] is False
|
||||||
|
assert journal.actions[0].outcome["blocked"] is True
|
||||||
@@ -14,9 +14,10 @@ from src.preset_manager import PresetManager
|
|||||||
|
|
||||||
async def _execute_without_run_context(execute_tool_block, *args, **kwargs):
|
async def _execute_without_run_context(execute_tool_block, *args, **kwargs):
|
||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
||||||
return await execute_tool_block(*args, **kwargs)
|
return await server_authorized_executor(execute_tool_block)(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class _FakeColumn:
|
class _FakeColumn:
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import json
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
from src.agent_evidence import CompletionRequirements, EvidenceLedger, EvidenceKind
|
from src.agent_evidence import CompletionRequirements, EvidenceLedger, EvidenceKind
|
||||||
from src.agent_runtime.completion import completion_answer, with_completion_gate
|
from src.agent_runtime.completion import completion_answer, with_completion_gate
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def builtin_action_info(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _req(user):
|
def _req(user):
|
||||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
return SimpleNamespace(state=SimpleNamespace(current_user=user), headers={})
|
||||||
|
|
||||||
|
|
||||||
def _endpoint(method, path):
|
def _endpoint(method, path):
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def _setup_db(tmp_path, monkeypatch):
|
|||||||
next_run = Column(DateTime)
|
next_run = Column(DateTime)
|
||||||
last_run = Column(DateTime)
|
last_run = Column(DateTime)
|
||||||
prompt = Column(Text, default='')
|
prompt = Column(Text, default='')
|
||||||
|
request_authority_json = Column(Text)
|
||||||
trigger_type = Column(String, default='schedule')
|
trigger_type = Column(String, default='schedule')
|
||||||
schedule = Column(String, default='daily')
|
schedule = Column(String, default='daily')
|
||||||
scheduled_time = Column(String, default='08:00')
|
scheduled_time = Column(String, default='08:00')
|
||||||
@@ -187,9 +188,11 @@ def test_running_task_cancel_keeps_event_loop_responsive_during_database_lock(tm
|
|||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
from src.builtin_actions import BUILTIN_ACTIONS
|
from src.builtin_actions import BUILTIN_ACTIONS
|
||||||
monkeypatch.setenv('BACKGROUND_TASK_FOREGROUND_GATE', 'false')
|
monkeypatch.setenv('BACKGROUND_TASK_FOREGROUND_GATE', 'false')
|
||||||
|
from src.agent_runtime.authority import seal_task_authority
|
||||||
with session_local() as db:
|
with session_local() as db:
|
||||||
db.add(ScheduledTask(id='running-task', owner='alice', name='Fixture action',
|
db.add(ScheduledTask(id='running-task', owner='alice', name='Fixture action',
|
||||||
task_type='action', action='fixture_wait', status='active'))
|
task_type='action', action='fixture_wait', status='active',
|
||||||
|
request_authority_json=seal_task_authority('', 'action', 'fixture_wait', owner='alice')))
|
||||||
db.commit()
|
db.commit()
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
async def external_action(**kwargs):
|
async def external_action(**kwargs):
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import time
|
|||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
from src.tool_approvals import ToolApprovalStore, document_content_digest
|
from src.tool_approvals import ToolApprovalStore, document_content_digest
|
||||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
|
|
||||||
def _make_block(tool_type, content):
|
def _make_block(tool_type, content):
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from src.tool_policy import (
|
|||||||
web_search_enabled_for_turn,
|
web_search_enabled_for_turn,
|
||||||
)
|
)
|
||||||
from src.turn_contract import requested_capabilities
|
from src.turn_contract import requested_capabilities
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
def _collect(gen):
|
def _collect(gen):
|
||||||
|
|||||||
@@ -1806,6 +1806,8 @@ def test_candidate_schema_changes_cannot_mutate_contract():
|
|||||||
async def test_dispatcher_enforces_bound_contract_without_external_mutations(monkeypatch):
|
async def test_dispatcher_enforces_bound_contract_without_external_mutations(monkeypatch):
|
||||||
from src import tool_execution, tool_implementations
|
from src import tool_execution, tool_implementations
|
||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
handler = AsyncMock(return_value={"events": [], "exit_code": 0})
|
handler = AsyncMock(return_value={"events": [], "exit_code": 0})
|
||||||
monkeypatch.setattr(tool_implementations, "do_manage_calendar", handler)
|
monkeypatch.setattr(tool_implementations, "do_manage_calendar", handler)
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import json
|
|||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def standalone_dispatch_authority(monkeypatch):
|
||||||
|
from src import tool_execution
|
||||||
|
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||||
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from src.agent_tools import ToolBlock, TOOL_TAGS # import first to avoid circul
|
|||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||||
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||||
from src.tool_security import is_public_blocked_tool
|
from src.tool_security import is_public_blocked_tool
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
def _run(content):
|
def _run(content):
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from src.tool_policy import WEB_TOOL_NAMES, ToolPolicy
|
|||||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
from src.tool_types import ToolBlock
|
from src.tool_types import ToolBlock
|
||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
from src.turn_contract import FAMILY_TOOLS
|
from src.turn_contract import FAMILY_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import tempfile
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
|
||||||
from src.tool_execution import (
|
from src.tool_execution import (
|
||||||
NO_TOOL_SECURITY_CONTEXT,
|
NO_TOOL_SECURITY_CONTEXT,
|
||||||
@@ -31,6 +32,9 @@ from src.tool_execution import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_execute_tool_block = server_authorized_executor(_execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
async def execute_tool_block(*args, **kwargs):
|
async def execute_tool_block(*args, **kwargs):
|
||||||
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
||||||
return await _execute_tool_block(*args, **kwargs)
|
return await _execute_tool_block(*args, **kwargs)
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ Listed for completeness. Setting one of these on a real install is either a no-o
|
|||||||
| `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:372` | Timeout in seconds for that call. Clamped to 15-120. |
|
| `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:372` | Timeout in seconds for that call. Clamped to 15-120. |
|
||||||
| `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:198` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. |
|
| `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:198` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. |
|
||||||
| `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7407` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. |
|
| `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7407` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. |
|
||||||
| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2070` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. |
|
| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2080` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. |
|
||||||
| `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:161` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. |
|
| `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:161` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. |
|
||||||
| `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:195` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. |
|
| `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:195` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. |
|
||||||
| `ODYSSEUS_SKIP_RUN_HINT` | *unset* | `setup.py:284` | Any non-empty value suppresses the `start the server with` hint at the end of setup. `start-macos.sh` sets it because it starts the server itself. |
|
| `ODYSSEUS_SKIP_RUN_HINT` | *unset* | `setup.py:284` | Any non-empty value suppresses the `start the server with` hint at the end of setup. `start-macos.sh` sets it because it starts the server itself. |
|
||||||
|
|||||||
Reference in New Issue
Block a user