Skip to content

feat: artifacts, and a universal public-exposure gate — TeamWork is trusted, a public link needs a person's decision - #260

Merged
praxagent merged 2 commits into
mainfrom
feat/artifacts
Oct 4, 2026
Merged

praxagent merged 2 commits into
mainfrom
feat/artifacts

Conversation

@praxagent

@praxagent praxagent commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Two commits:

Guides: docs/guides/artifacts.md, docs/security/public-exposure.md.

1. Artifacts

  • The store. artifact_service keeps self-contained HTML pages in the workspace (artifacts/<id>/index.html + manifest.json).
    • Every write is a git commit.
    • Pages are size-capped (ARTIFACT_MAX_BYTES), and ids can't escape the folder.
  • The tools:
    • artifact_publish(title, html, artifact_id="") creates an artifact, or makes a new version of one;
    • artifact_list;
    • artifact_share_public(artifact_id, hours) and artifact_unshare(artifact_id).
  • How the model shows one. It puts [artifact:<id>] on its own line. TeamWork renders it live, in a sandboxed iframe with no network.
  • For TeamWork's viewer. Routes /teamwork/artifacts and /teamwork/artifacts/<id>[?meta=1] sit behind the existing PRAX_API_KEY check.
  • Public artifact links. They expire (ARTIFACT_PUBLIC_HOURS, default 24, at most 168), always show the latest version, and are revoked by artifact_unshare. /shared serves HTML with Content-Security-Policy: sandbox allow-scripts and nosniff.

2. The universal public-exposure gate

TJ: TeamWork is considered secure, so the deployer has to secure it, and the docs now say so in both repos. A public (ngrok) link is not secure: there is no password, so anyone with the link can open it. Putting anything there needs an explicit approval.

  • Every publishing tool is an always-on hard floor, and holds even with HARD_FLOORS_ENABLED off. The tools are artifact_share_public, workspace_share_file and course_publish(public=True); public=False stays in TeamWork and is not gated.
  • What counts as a decision. One of two things:
    • a person approves that exact call in TeamWork. The prompt says the link will be PUBLIC, with no password, and a timed grant doesn't count;
    • the user's own message asks for it by name: a share verb plus the file, artifact or course. "Yes" or "go ahead" is refused.
  • A new code path can't skip the gate. The share registry, the only thing that makes anything publicly reachable, refuses to register a share unless the call is running inside a person's decision (prax/services/exposure_gate.py). A plugin or a future feature that tries to publish directly gets ExposureNotApproved, not a link. Every share records approved_by.
  • No silent fallback. workspace_send_file used to make a public link whenever it couldn't deliver a file directly. Now the file stays in TeamWork's file browser, and Prax offers a link the user would approve.
  • Over a tunnel, Prax's private routes are closed.
    • With PRAX_API_KEY unset, a request to /teamwork/*, /plugins/* or /api/users/* that carries forwarding headers (X-Forwarded-*, Forwarded, X-Real-IP) gets 403. Setting: PRAX_TUNNEL_REQUESTS_NEED_KEY, default on.
    • An ngrok tunnel to Prax's port publishes every route, and these are private. TeamWork's own calls are direct and carry no such headers. With the key set, the key decides.
  • Notes. No agent tool publishes a note with public=True. A direct caller now gets "(not made public: …)" instead of a link.

Behaviour changes to know before deploying

  • workspace_share_file and course_publish(public=True) used to run on MEDIUM risk. Now they need the user's decision.
  • A file that can't be delivered directly is no longer auto-shared publicly.
  • If PRAX_URL in TeamWork's .env points through a reverse proxy or tunnel (not straight at Prax), TeamWork's panels will now get 403 unless PRAX_API_KEY is set in both .env files. After deploy, check that the TeamWork panels load. Setting the key is the recommended setup anyway.

Fix found on the way (affects hard floors already on main)

  • The bug. At the hub, the floor gate keyed a call before governance popped expected_observation, and the HIGH-risk gate keyed it after. That gave one call two keys.
  • The effect. A person's floor approval wasn't recognised by the HIGH gate, which asked again: twice with out-of-band approvals on.
  • The fix. _trifecta_key now ignores expected_observation. Spoke-layer floors (the browser credential tools) were not affected.
  • The test. A hub-level regression test fails on the old key.

Verified

  • Artifact tests: 31 new, plus the hard-floor regression.
  • 15 exposure tests:
    • the registry refuses outside a decision, and records who approved;
    • an ungated tool calling the registry fails closed;
    • file share: refused without a person, allowed when the user names the file, "yes" refused, and the prompt says PUBLIC and no password;
    • course: public=False not gated, public=True refused, allowed when the user names the course;
    • send_file never makes a link;
    • a public note is refused;
    • tunnel guard: 403 through a tunnel without a key, the key decides when set, and the guard can be turned off.
  • Mutation check. Removing the registry guard fails 3 of the 15 exposure tests.
  • make ci: green, 4103 passed, with 0 secrets-proxy requests during the run.
  • Not yet run live end to end (real ngrok, and TeamWork showing a real artifact). There's a VERIFICATION_LEDGER row for it.

Idea credit: Telepath's Television.

…ork, public only by a person's decision

- artifact_service: self-contained HTML pages in the workspace
  (artifacts/<id>/index.html + manifest), every write a git commit, size and
  id limits. Tools artifact_publish / artifact_list / artifact_share_public /
  artifact_unshare, and /teamwork/artifacts routes for TeamWork's viewer.
- A public link has no password, so artifact_share_public is an always-on
  hard floor (holds with HARD_FLOORS_ENABLED off): a person approves that
  exact share in TeamWork (timed grants don't count) or asks for it by name.
  Links expire (ARTIFACT_PUBLIC_HOURS, max 168) and can be revoked.
- /shared serves HTML with CSP 'sandbox allow-scripts' (artifacts: no
  network too) and nosniff.
- Fix: at the hub the floor gate and the HIGH-risk gate keyed one call
  differently (expected_observation popped in between), so a person's floor
  approval was asked for again. The key now ignores expected_observation.
ARTIFACTS_ENABLED defaults off. Idea credit: Telepath's Television.
…link needs a person's decision

TJ: TeamWork is the trusted surface (the deployer must secure it); a public
(ngrok) link has no password, so putting anything there needs an explicit
approval, every time.
- Every publishing tool is an always-on hard floor: artifact_share_public,
  workspace_share_file, course_publish(public=True). A person approves that
  exact call in TeamWork (timed grants don't count; the prompt says it will
  be PUBLIC) or asks in their own words naming the thing.
- The share registry refuses any public share made outside a person's
  decision (exposure_gate); a new or plugin path fails closed. Shares record
  approved_by.
- workspace_send_file no longer falls back silently to a public link.
- Over a tunnel, Prax's private routes are closed when PRAX_API_KEY is unset
  (PRAX_TUNNEL_REQUESTS_NEED_KEY, default on); TeamWork's direct calls are
  unaffected.
- docs/security/public-exposure.md: the rule, and securing TeamWork as the
  deployer's job.
@praxagent praxagent changed the title feat: artifacts — pages Prax makes and keeps updating, shown in TeamWork, public only by a person's decision feat: artifacts, and a universal public-exposure gate — TeamWork is trusted, a public link needs a person's decision Oct 3, 2026
@praxagent
praxagent merged commit 2ea5c18 into main Oct 4, 2026
1 check passed
@praxagent
praxagent deleted the feat/artifacts branch October 4, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant