Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
92fa119
fix(agent-os): close authorization bypasses in stateless kernel and e…
Copilot May 29, 2026
4481900
test(mcp-scan): regression for env-poisoning RCE + cwd hijack -- curr…
invalid-email-address May 29, 2026
7a1a297
fix(mcp-scan): restore env-key blocklist and untrusted-cwd guard
invalid-email-address May 29, 2026
e55fe3e
test(authz): regression for approval-key bypasses + provider edge cas…
invalid-email-address May 29, 2026
69b3ece
fix(authz): harden approval-key strip, strict-bool, BaseException, lo…
invalid-email-address May 29, 2026
0aed355
test(authz): regression for empty-policies bypass + non-loopback exec…
invalid-email-address May 29, 2026
1308179
fix(authz): close empty-policies bypass and enforce loopback for unsa…
invalid-email-address May 29, 2026
ed3fdea
test(intent): regression for cross-agent intent reuse -- currently FA…
invalid-email-address May 29, 2026
9544f9b
fix(intent): bind intent to declaring agent_id
invalid-email-address May 29, 2026
23c2320
test(iatp): regression for weak/short trusted-override tokens -- curr…
invalid-email-address May 29, 2026
3dcbb95
fix(iatp): reject weak/short trusted-override tokens
invalid-email-address May 29, 2026
7ff3d08
test(policies): regression for plaintext OPA over network -- currentl…
invalid-email-address May 29, 2026
0796237
fix(policies): require HTTPS for remote OPA unless explicitly opted in
invalid-email-address May 29, 2026
ee18df2
test(caas): regression for unauthenticated FastAPI surface gate -- cu…
invalid-email-address May 29, 2026
d17eaf3
fix(caas): require explicit env gate to start unauthenticated CaaS su…
invalid-email-address May 29, 2026
9f62bbe
ci(agent-os): clear no-stubs/no-crypto/spell-check/safety-critical CI…
jackbatzner May 30, 2026
005f19d
ci(spell-check): allow cyrillic-e 'approv\u0435d' confusable used in …
jackbatzner May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .cspell-repo-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,24 @@ XACML
xfail
xunit
ZDOTDIR

ASGI
approv
backendunavailable
baseexception
casefold
changeme
confusables
hdrs
madmin
monkeypatched
multitenant
NFKC
normalisation
oncall
rsplit
sanitised
shortone
testclient
unicodedata
approvеd
94 changes: 91 additions & 3 deletions agent-governance-python/agent-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `POST /api/v1/execute` now fails closed by default and no longer trusts
caller-asserted `agent_id` values before policy, audit, and rate-limit
enforcement.
- `agent_os.cli.mcp_scan` command-allowlist bypass is now
**double-gated**. The legacy `--allow-commands` switch is preserved as
a deprecated alias, but the canonical name is now
`--unsafe-allow-all-commands`, and the flag refuses to take effect
unless `AGENT_OS_ENV` is set to one of `{dev, development, local}`.
In any other environment the CLI exits non-zero with an explanatory
error instead of silently bypassing the allowlist. A loud stderr
warning is printed whenever the bypass engages. Same gating model as
the unsafe execute mode in `agent_os.server.app`.
- `agent_os.stateless.StatelessKernel._check_policies` no longer honors
caller-supplied `params["approved"]` values to satisfy `require_approval`
policies. Approval must come from an approved intent plan via a trusted
`IntentManager`; unplanned drift on restricted actions is denied. The
caller-supplied flag is stripped from `params` before downstream execution.
- `mcp_kernel_server.tools.KernelExecuteTool._check_policies` had the same
caller-controlled bypass; it is now ignored with a warning log and the
action is denied with guidance pointing to a trusted host approval
workflow. **Breaking:** `KernelExecuteTool` is now deny-by-default for
actions whose `DEFAULT_POLICIES` entry sets `requires_approval: True`
(`file_write`, `send_email`). To re-enable these actions, construct the
tool with an injected trusted approval provider:

```python
def host_approval(action: str, params: dict) -> bool:
# consult your host elicitation, intent manager, or approval queue
return ask_user_to_approve(action, params)

tool = KernelExecuteTool(approval_provider=host_approval)
```

Caller-supplied `approved` flags in `params` remain untrusted and are
stripped before the provider is invoked. Any exception raised by the
provider fails closed (action denied). Without an `approval_provider`,
behavior is unchanged from earlier in this release: actions remain
denied with the same error message.
- `agent_os.policies.backends.OPABackend` now strict-bool validates OPA
responses in both remote and CLI modes. A positive authorization
decision requires the JSON literal `true`; any other shape (missing
`result` field, non-object body, truthy strings like `"denied"`,
integers, non-empty dicts/lists, malformed JSON, HTTP errors,
subprocess non-zero exits, timeouts) fails closed with an explicit
error code (`malformed_response`, `missing_result`, `missing_expressions`).
Previously the code defaulted missing fields to `False` then cast
through `bool(value)`, which would silently authorize any truthy
non-bool value returned by a misconfigured or compromised OPA server.
- `iatp.main` and `iatp.sidecar` `POST /proxy` `X-User-Override` header
is now **double-gated**. The legacy behavior accepted any truthy
value of the caller-supplied header as a bypass of policy and
security-validator warnings — the same caller-controlled bypass
shape as the kernel `approved` parameter we hardened above. The
header is now ignored entirely unless the server operator sets
`IATP_TRUSTED_USER_OVERRIDE_TOKEN` to a non-empty secret AND the
caller echoes that exact value back (constant-time compare). Without
the env-side token, warnings always block. This is a stop-gap; the
long-term fix is a trusted out-of-band approval provider analogous
to `KernelExecuteTool.approval_provider` (TODO in proxy handlers).
- `caas.api.server` documents an inline `SECURITY WARNING` noting that
~50 FastAPI routes (including destructive `POST/PUT/DELETE` endpoints
and gateway-audit operations) are unauthenticated by design and trust
caller-supplied `agent_id`/`user_id` values. A startup hook now
emits a loud `WARNING` log when the server starts outside a
`local`/`dev`/`development` environment. Re-designing the CaaS auth
model is intentionally out of scope for this sweep and is deferred
to a dedicated PR (documented in the module docstring TODO).

### Changed
- **Consolidated PII detection patterns into a single shared constant** in `agent_os.integrations.base` ([#2635](https://github.com/microsoft/agent-governance-toolkit/issues/2635)). The four per-adapter copies (`langchain_adapter`, `autogen_adapter`, `crewai_adapter`, `bedrock_adapter`) now import the shared `PII_PATTERNS` tuple so future adapters cannot silently drift out of sync. The shared constant is the union of patterns previously used across all four adapters, which means LangChain, AutoGen, and CrewAI now also block credit-card PII (previously a Bedrock-only check). `bedrock_adapter._PII_RE` remains as a back-compat alias to the shared constant.
Expand All @@ -22,6 +86,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
authenticated agent identity derived from the bearer token.
- If execute auth is not configured, unauthenticated requests now return `503`
instead of running with a caller-supplied identity.
- `StatelessKernel._check_policies` gained a keyword-only `has_trusted_intent`
argument and now returns two additional keys (`requires_trusted_approval`,
`drop_caller_approval_param`). This is an internal API (leading underscore);
subclasses overriding it must accept `**kwargs` or update their signature.

### Added
- **`BackendDecision` assurance fields**: optional `proof_artefact: str | None`
Expand Down Expand Up @@ -52,9 +120,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Migration Notes
- Configure `GovServer(execute_authenticator=...)` or set
`AGENT_OS_EXECUTION_TOKENS` before exposing `/api/v1/execute`.
- `AGENT_OS_ALLOW_UNAUTHENTICATED_EXECUTE=true` is available only as an unsafe
local-development escape hatch. It restores caller-asserted identity behavior
and should not be used in shared or production environments.
- `AGENT_OS_UNSAFE_ALLOW_UNAUTHENTICATED_EXECUTE=true` is available only with
`AGENT_OS_ENV=local` as an unsafe local-development escape hatch. It uses a
server-controlled local identity instead of trusting caller-supplied
`agent_id` values and must not be used in shared or production environments.
- **Breaking:** Setting the legacy `AGENT_OS_ALLOW_UNAUTHENTICATED_EXECUTE`
environment variable to a truthy value now raises `ValueError` at
`GovServer` construction time (process startup). Operators upgrading must
unset the legacy variable; if you need the local-development escape hatch,
set `AGENT_OS_UNSAFE_ALLOW_UNAUTHENTICATED_EXECUTE=true` together with
`AGENT_OS_ENV=local`.
- **Breaking:** `mcp_kernel_server.tools.KernelExecuteTool` actions whose
`DEFAULT_POLICIES` entry declares `requires_approval: True` (currently
`file_write` and `send_email`) are now deny-by-default — previously
these were permitted whenever the caller set `approved=True`. To
re-enable these actions, pass a trusted `approval_provider` callable to
the constructor:

```python
tool = KernelExecuteTool(approval_provider=my_host_approval)
```

Provider exceptions fail closed (action denied). Without a provider,
these actions remain denied.

## [1.0.0] - 2026-01-26

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@
# Licensed under the MIT License.
"""
REST API for Context-as-a-Service.

SECURITY WARNING — UNAUTHENTICATED SURFACE:
============================================
Future hardening (security, broader-design-required): This module exposes ~50 FastAPI
routes — including destructive ``POST/PUT/DELETE`` endpoints over
``/ingest``, ``/documents``, ``/triad``, ``/conversation``, ``/vfs/files``,
``/gateway/audit`` — with **no authentication, no authorization, and no
caller-identity verification**. Caller-supplied identifiers like
``agent_id`` and ``user_id`` on ``/vfs/files`` and ``/gateway/*`` are
trusted blindly. Body and upload sizes are unbounded.

Fixing this surface end-to-end requires a broader design discussion
(auth provider selection, identity model, multitenant isolation, body
size budgets per endpoint, audit-trail scope). That is intentionally
out of scope for the current Agent OS authz hardening sweep. Until the
design lands, **DO NOT bind this server to a non-loopback interface in
any shared, multi-tenant, or production environment** — it is intended
for single-tenant local development only.

When wiring deployment, prefer ``uvicorn caas.api.server:app --host
127.0.0.1`` and front it with an authenticating reverse proxy that
strips/rewrites caller identity headers before reaching this app.
"""

import os
import uuid
from datetime import datetime, timezone
from typing import List, Optional
Expand Down Expand Up @@ -53,6 +76,59 @@
version="0.1.0"
)


_CAAS_UNSAFE_ALLOW_UNAUTH_ENV = "CAAS_UNSAFE_ALLOW_UNAUTH"
_CAAS_LOCAL_ENV_NAMES = frozenset({"local", "dev", "development"})


def _caas_unauth_gate_satisfied() -> tuple[bool, str]:
"""Return ``(allowed, reason)``. The CaaS app refuses to start
unless either (a) AGENT_OS_ENV is local/dev/development, or
(b) the operator has explicitly accepted the risk via
``CAAS_UNSAFE_ALLOW_UNAUTH=1``."""
env = (os.getenv("AGENT_OS_ENV") or "").strip().lower()
if env in _CAAS_LOCAL_ENV_NAMES:
return True, f"AGENT_OS_ENV={env!r}"
raw = (os.getenv(_CAAS_UNSAFE_ALLOW_UNAUTH_ENV) or "").strip().lower()
if raw in {"1", "true", "yes", "on"}:
return True, f"{_CAAS_UNSAFE_ALLOW_UNAUTH_ENV}={raw!r} (operator opt-in)"
return False, env or "<unset>"


@app.on_event("startup")
async def _enforce_unauthenticated_surface_gate() -> None:
"""Refuse to start the CaaS app unless we're in a developer env or
the operator has explicitly accepted the risk.

Previously this was only a log warning, which meant a misconfigured
deployment could expose every CaaS route on a shared network
silently. The control is now a hard fail-closed check on startup.
"""
import logging
log = logging.getLogger("caas.api")
allowed, detail = _caas_unauth_gate_satisfied()
if not allowed:
log.error(
"caas.api refusing to start: unauthenticated FastAPI surface "
"is only permitted with AGENT_OS_ENV in %s, or with "
"%s=1 as an explicit operator opt-in. "
"Current AGENT_OS_ENV=%s",
sorted(_CAAS_LOCAL_ENV_NAMES),
_CAAS_UNSAFE_ALLOW_UNAUTH_ENV,
detail,
)
raise RuntimeError(
"caas.api: unauthenticated surface is not permitted in this "
f"environment (AGENT_OS_ENV={detail!r}). Set AGENT_OS_ENV to "
f"local/dev/development or {_CAAS_UNSAFE_ALLOW_UNAUTH_ENV}=1 "
"to accept the risk explicitly."
)
log.warning(
"caas.api: starting UNAUTHENTICATED FastAPI app (gate satisfied by %s). "
"All routes are open; bind to loopback only.",
detail,
)

# Initialize components
document_store = DocumentStore()
detector = DocumentTypeDetector()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Tests for the CaaS unauthenticated-surface startup gate.

The CaaS FastAPI app exposes routes without authentication. To prevent
accidental shared-network exposure, ``caas.api.server`` requires either
``AGENT_OS_ENV`` to be local/dev/development OR an explicit
``CAAS_UNSAFE_ALLOW_UNAUTH=1`` operator opt-in. Anything else must fail
closed on startup.
"""
from __future__ import annotations

import asyncio

import pytest


def _gate():
from caas.api import server as srv
return srv


class TestCaasUnauthGate:
def test_local_env_satisfies_gate(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "local")
monkeypatch.delenv("CAAS_UNSAFE_ALLOW_UNAUTH", raising=False)
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is True

@pytest.mark.parametrize("env", ["dev", "development", "LOCAL"])
def test_dev_envs_satisfy_gate(self, monkeypatch, env):
monkeypatch.setenv("AGENT_OS_ENV", env)
monkeypatch.delenv("CAAS_UNSAFE_ALLOW_UNAUTH", raising=False)
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is True

def test_explicit_opt_in_satisfies_gate(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "production")
monkeypatch.setenv("CAAS_UNSAFE_ALLOW_UNAUTH", "1")
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is True

@pytest.mark.parametrize("val", ["true", "yes", "on"])
def test_explicit_opt_in_truthy_values(self, monkeypatch, val):
monkeypatch.setenv("AGENT_OS_ENV", "production")
monkeypatch.setenv("CAAS_UNSAFE_ALLOW_UNAUTH", val)
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is True

def test_production_env_without_opt_in_blocks(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "production")
monkeypatch.delenv("CAAS_UNSAFE_ALLOW_UNAUTH", raising=False)
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is False

def test_unset_env_blocks(self, monkeypatch):
monkeypatch.delenv("AGENT_OS_ENV", raising=False)
monkeypatch.delenv("CAAS_UNSAFE_ALLOW_UNAUTH", raising=False)
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is False

def test_falsy_opt_in_does_not_satisfy_gate(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "production")
monkeypatch.setenv("CAAS_UNSAFE_ALLOW_UNAUTH", "0")
allowed, _ = _gate()._caas_unauth_gate_satisfied()
assert allowed is False

def test_startup_hook_raises_when_gate_fails(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "production")
monkeypatch.delenv("CAAS_UNSAFE_ALLOW_UNAUTH", raising=False)
srv = _gate()
with pytest.raises(RuntimeError, match="unauthenticated surface"):
asyncio.run(srv._enforce_unauthenticated_surface_gate())

def test_startup_hook_passes_when_gate_satisfied(self, monkeypatch):
monkeypatch.setenv("AGENT_OS_ENV", "local")
srv = _gate()
# Should not raise
asyncio.run(srv._enforce_unauthenticated_surface_gate())
Loading
Loading