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
- 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.
- 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.
- 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)
- 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.
- 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.
- Let
publish_artifact accept an optional visibility spec.
- 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.
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.
Summary
The
Visibilitymodel (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, everyPrivate,LabelledandTenantartifact 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
BaseContextProviderapplies visibility in the base class β its docstring says "MANDATORY β¦ CANNOT BE BYPASSED" β andorchestrator/scheduler.pycalls_check_visibility(artifact, identity)before dispatch.components/server/artifacts/artifacts_component.pynever constructs anAgentIdentityand never callsVisibility.allows(). None of the four handlers take an identity or aDepends(...)guard.orchestrator/server_manager.pycomposes the default set asHealthAndMetricsComponent,AgentsServerComponent,ControlRoutesComponent,ArtifactsComponentβ all tagged"Public API".Flock.serve()defaults touse_default_components=True.AuthenticationComponentis not in that set.Reproduction
Actual output
Three distinct problems
GET /artifactsreturns restricted artifacts;GET /artifacts/{id}is a bareawait orchestrator.store.get(artifact_id)β serialize β return, with no check whatsoever. Thevisibilityquery parameter onlist_artifacts/summarize_artifactsis a display filter (which kinds to show), not an access check._serialize_artifactemits"visibility": artifact.visibility.model_dump(mode="json")next to the data, so a caller is told an artifact wasPrivate(agents=["reviewer"])in the same response that hands them its contents./artifacts/summarylikewise reportsby_visibilitycounts across restricted artifacts.publish_artifactcallsorchestrator.publish({...})with novisibilityargument, which resolves viaensure_visibility(None)βPublicVisibility(). Anything published over the API is Public regardless of intent.Why
AuthenticationComponentdoes not close thisAuthenticationComponentis route-pattern based (path_patternregex,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)
ArtifactsComponenta way to resolve a caller into anAgentIdentity(name,labels,tenant_id) β from the authenticated principal whenAuthenticationComponentis present, from an explicit configured default otherwise.Visibility.allows(identity)the context providers already use.list_artifactsandsummarize_artifactsfilter;get_artifactreturns 404 rather than 403 on a visibility miss so existence is not disclosed.publish_artifactaccept an optional visibility spec.OutputProcessorengine-contract checks); fail-open preserves today's behaviour and should then be an explicit documented opt-in rather than the silent default.AgentsServerComponentandControlRoutesComponentare also default-on and were not reviewed here β they likely deserve the same audit.Suggested regression test
Publish one
Publicand onePrivate(agents={"a"})artifact; assert that an unauthenticatedGET /api/v1/artifactsreturns exactly one item,GET /api/v1/artifacts/{private_id}returns 404, and/artifacts/summarycounts 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/skillsvsmainindependently 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 inpayload_summaryand 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 local127.0.0.1server 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.