Skip to content

πŸ”“ [BUG] Artifact API does not apply the Visibility model β€” Private/Tenant artifacts are readable unauthenticated by defaultΒ #417

Description

@AndreRatzenberger

Summary

The Visibility model (Public / Private / Labelled / Tenant / After) is enforced uncircumventably on the agent path and not at all on the HTTP path. With the default server component set, every Private, Labelled and Tenant artifact is readable, unauthenticated, by anyone who can reach the port.

Verified by execution on main @ c13f1f0a (v0.5.600), not just by reading source.

Related to #280, but filed separately: #280 is the 1.0 authn/authz feature, while this is the current default posture of a shipped, default-on surface. #280 already notes the intent ("make sure the solution interplays with visibility rules (Private/Tenant/Labelled)") β€” this issue records what happens today and proposes a narrow fix that does not depend on the full 1.0 work.

Where enforcement exists, and where it stops

  • Agent path (correct). BaseContextProvider applies visibility in the base class β€” its docstring says "MANDATORY … CANNOT BE BYPASSED" β€” and orchestrator/scheduler.py calls _check_visibility(artifact, identity) before dispatch.
  • HTTP path. components/server/artifacts/artifacts_component.py never constructs an AgentIdentity and never calls Visibility.allows(). None of the four handlers take an identity or a Depends(...) guard.

orchestrator/server_manager.py composes the default set as HealthAndMetricsComponent, AgentsServerComponent, ControlRoutesComponent, ArtifactsComponent β€” all tagged "Public API". Flock.serve() defaults to use_default_components=True. AuthenticationComponent is not in that set.

Reproduction

import asyncio, json, urllib.request
from pydantic import BaseModel
from flock import Flock
from flock.core.visibility import PrivateVisibility, PublicVisibility
from flock.registry import flock_type

PORT = 8399
BASE = f"http://127.0.0.1:{PORT}/api/v1"

@flock_type
class Secret(BaseModel):
    label: str
    payload: str

def _get(path):
    with urllib.request.urlopen(f"{BASE}{path}", timeout=10) as r:
        return r.status, json.loads(r.read().decode())

async def get(path):
    return await asyncio.to_thread(_get, path)

async def main():
    flock = Flock()
    await flock.publish(Secret(label="public-one", payload="not sensitive"),
                        visibility=PublicVisibility())
    priv = await flock.publish(Secret(label="private-one", payload="TENANT-CONFIDENTIAL-XYZ"),
                               visibility=PrivateVisibility(agents={"reviewer"}))

    await flock.serve(host="127.0.0.1", port=PORT, blocking=False)
    await asyncio.sleep(3)

    print(await get("/artifacts"))
    print(await get(f"/artifacts/{priv.id}"))
    print(await get("/artifacts/summary"))

asyncio.run(main())

Actual output

published private id=edb2e864-...  visibility=Private agents={'reviewer'}

=== GET /artifacts (unauthenticated) ===
status=200  items returned=2
   label='public-one'  visibility_kind='Public'   payload='not sensitive'
   label='private-one' visibility_kind='Private'  payload='TENANT-CONFIDENTIAL-XYZ'

=== GET /artifacts/{private_id} (unauthenticated) ===
status=200
  visibility field returned: {"kind": "Private", "agents": ["reviewer"]}
  payload returned: {"label": "private-one", "payload": "TENANT-CONFIDENTIAL-XYZ"}

=== GET /artifacts/summary (unauthenticated) ===
status=200  summary={"total": 2, ..., "by_visibility": {"Public": 1, "Private": 1}, ...}

Three distinct problems

  1. Reads ignore visibility. GET /artifacts returns restricted artifacts; GET /artifacts/{id} is a bare await orchestrator.store.get(artifact_id) β†’ serialize β†’ return, with no check whatsoever. The visibility query parameter on list_artifacts / summarize_artifacts is a display filter (which kinds to show), not an access check.
  2. The restriction is disclosed alongside the payload. _serialize_artifact emits "visibility": artifact.visibility.model_dump(mode="json") next to the data, so a caller is told an artifact was Private(agents=["reviewer"]) in the same response that hands them its contents. /artifacts/summary likewise reports by_visibility counts across restricted artifacts.
  3. HTTP publish cannot express a non-public artifact. publish_artifact calls orchestrator.publish({...}) with no visibility argument, which resolves via ensure_visibility(None) β†’ PublicVisibility(). Anything published over the API is Public regardless of intent.

Why AuthenticationComponent does not close this

AuthenticationComponent is route-pattern based (path_pattern regex, exclude_paths). It answers "may you call this endpoint"; visibility answers "which artifacts may you see". Different axes β€” an authenticated caller still receives every artifact the route returns. Adding auth reduces exposure to authenticated principals but does not restore per-artifact visibility.

Proposed fix (existing primitives, no new concepts)

  1. Give ArtifactsComponent a way to resolve a caller into an AgentIdentity (name, labels, tenant_id) β€” from the authenticated principal when AuthenticationComponent is present, from an explicit configured default otherwise.
  2. Filter every read path through the same Visibility.allows(identity) the context providers already use. list_artifacts and summarize_artifacts filter; get_artifact returns 404 rather than 403 on a visibility miss so existence is not disclosed.
  3. Let publish_artifact accept an optional visibility spec.
  4. Decide the no-identity default. Fail-closed is consistent with how the framework treats untrusted input elsewhere (cf. the OutputProcessor engine-contract checks); fail-open preserves today's behaviour and should then be an explicit documented opt-in rather than the silent default.
  5. AgentsServerComponent and ControlRoutesComponent are also default-on and were not reviewed here β€” they likely deserve the same audit.

Suggested regression test

Publish one Public and one Private(agents={"a"}) artifact; assert that an unauthenticated GET /api/v1/artifacts returns exactly one item, GET /api/v1/artifacts/{private_id} returns 404, and /artifacts/summary counts exclude the private artifact. The default component set makes this a two-line fixture.

Sequencing note for the changelog work

The 2026-05-07 subagent review of feat/skills vs main independently found the branch form of this ("token scopes and visibility are modeled but not consistently enforced on the served routes"), plus that the changelog stores full artifact payloads in payload_summary and replays them through unscoped stream/cursor endpoints.

Merging the changelog as currently designed therefore widens this gap rather than leaving it flat, by adding a second unscoped read path over the same data. Worth landing the visibility enforcement together with, or ahead of, the durability work.

Scope

Static review plus the reproduction above, on main @ c13f1f0a (v0.5.600). Reviewed: core/visibility.py, core/context_provider.py, orchestrator/scheduler.py, orchestrator/server_manager.py, components/server/artifacts/artifacts_component.py, components/server/auth/auth_component.py, agent/output_processor.py, docs/guides/visibility.md. No deployed instance was probed β€” only a local 127.0.0.1 server started by the script above.


Filed after an architecture review of the visibility/enforcement model. Analysis by Claude (Anthropic Claude Opus 5); reproduction executed locally.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions