Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 27 additions & 25 deletions go/internal/httpapi/smith_autonomy_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ package httpapi
// does, the same "always the safe direction" posture reject/checkpoint-abort
// use elsewhere in this API. Because the decision depends on the request
// BODY (which must be parsed first), it can't be expressed as a route-level
// requireAssurance(...) middleware wrap the way every other step-up-gated
// route in this file is — so this handler evaluates the policy directly,
// duplicating requireAssurance's core check rather than complicating that
// shared middleware with a body-dependent special case.
// requireAssurance(...)/requireStrictAssurance(...) middleware wrap the way
// every other step-up-gated route in this file is — so this handler calls
// the shared evaluateAssurance helper directly on the escalating path.
//
// It deliberately uses the requireStrictAssurance shape (no bearer bypass),
// not requireAssurance's blanket one: escalating smith's standing autonomy
// is at least as sensitive as key management (#37), since it's the switch
// that lets smith execute system-modifying procedures unattended.

import (
"context"
"encoding/json"
"net/http"
"time"

"github.com/jsaigou/the-forge/internal/authz"
"github.com/jsaigou/the-forge/internal/smith"
Expand Down Expand Up @@ -128,26 +131,25 @@ func (s *Server) handleSmithAutonomyPut(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnauthorized, "Authentication required")
return
}
if ident.KeyID == "" && s.deps.PolicyStore != nil {
pctx, cancel := context.WithTimeout(ctx, 2*time.Second)
policy, err := s.deps.PolicyStore.Load(pctx)
cancel()
if err != nil {
writeError(w, http.StatusInternalServerError, "policy load failed")
return
}
ttl := s.deps.StepUpTTL
if ttl == 0 {
ttl = authz.DefaultStepUpTTL
}
eval := authz.NewPolicyEvaluator(policy, ttl, time.Now)
decision := eval.Evaluate(authz.ResourceActionSmithAutonomy, ident.Assurance, ident.AssuranceAt)
if !decision.Allowed {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "step_up_required", "required": string(decision.Required), "resource": decision.Resource,
})
return
}
// No bearer bypass here — unlike requireAssurance's blanket one for
// ordinary a0/MCP traffic, escalating smith's standing autonomy is at
// least as sensitive as key management (#37's requireStrictAssurance
// carve-out), since it's the switch that lets smith execute
// system-modifying procedures unattended. Every identity is
// evaluated through the same evaluateAssurance path requireAssurance/
// requireStrictAssurance use; a bearer identity carries no session
// assurance, so it always fails a password-or-above resource here —
// only a stepped-up browser session can flip this switch on.
allowed, decision, err := s.evaluateAssurance(ctx, ident, authz.ResourceActionSmithAutonomy)
if err != nil {
writeError(w, http.StatusInternalServerError, "policy load failed")
return
}
if !allowed {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "step_up_required", "required": string(decision.Required), "resource": decision.Resource,
})
return
}
}

Expand Down
45 changes: 45 additions & 0 deletions go/internal/httpapi/smith_autonomy_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,48 @@ func TestHandleSmithAutonomy_EscalationRequiresStepUp(t *testing.T) {
t.Errorf("policy.Enabled = true after refused escalations, want false")
}
}

// TestHandleSmithAutonomy_BearerKeyCannotEscalateWithoutStepUp is the
// regression test for the smith-autonomy analogue of issue #37: before this
// fix, the escalating branch only ever evaluated step-up when
// ident.KeyID == "" (a session), so ANY bearer forge key with RoleOperator+
// — even one never granted a human step-up — could flip smith's standing
// autonomy on and let it start executing system-modifying procedures
// unattended. A non-escalating PUT (disabling) must still work over bearer,
// same as #37's self-rotation carve-out preserved ordinary bearer use.
func TestHandleSmithAutonomy_BearerKeyCannotEscalateWithoutStepUp(t *testing.T) {
s, auth, _ := serverWithSmithActionsAuth(t)
token, err := auth.MintKey(t.Context(), authz.KindForge, "admin-bot", "", authz.RoleAdmin, "", time.Time{})
if err != nil {
t.Fatalf("MintKey: %v", err)
}

req := httptest.NewRequest("PUT", "/api/v1/smith/autonomy", bytes.NewBufferString(`{"enabled":true}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("bearer PUT enabled:true = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
var errResp map[string]string
json.NewDecoder(rec.Body).Decode(&errResp)
if errResp["error"] != "step_up_required" || errResp["resource"] != authz.ResourceActionSmithAutonomy {
t.Errorf("errResp = %+v, want step_up_required for action.smith.autonomy", errResp)
}

// A non-escalating PUT (disabling) over the same bearer key still works.
req = httptest.NewRequest("PUT", "/api/v1/smith/autonomy", bytes.NewBufferString(`{"enabled":false}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer PUT enabled:false = %d, want 200; body=%s", rec.Code, rec.Body.String())
}

final := s.deps.Smith.AutonomyPolicy(context.Background())
if final.Enabled {
t.Errorf("policy.Enabled = true after a refused bearer escalation, want false")
}
}
41 changes: 10 additions & 31 deletions go/internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,11 @@ func (s *catalogSnapshot) resolveLogos(cfgLogo, cfgLogoDark string, mdl store.Mo
}

// resolveModalities (Sprint J1) narrows a model's architectural modalities
// to what one specific config can actually deliver:
// to what one specific config can actually deliver. The precedence itself
// now lives in store.ResolveModalities (2026-09-13, so a0's /v1/models and
// this package's cards can never disagree) — this is a thin adapter that
// resolves the mmproj-missing lookup against this snapshot's artifact map
// and re-shapes the result into this package's []ModalityGap wire type:
//
// 1. "text" is always enabled — every config can do plain text.
// 2. An explicit cfg.Modalities override wins verbatim, even an empty one
Expand All @@ -393,37 +397,12 @@ func (s *catalogSnapshot) resolveLogos(cfgLogo, cfgLogoDark string, mdl store.Mo
// unavailable too ("mmproj file missing on disk") rather than silently
// claiming a capability the config can't currently serve.
func (s *catalogSnapshot) resolveModalities(c store.Config, mdl store.Model) (enabled []string, unavailable []ModalityGap) {
nonText := func(mods []string) []string {
out := make([]string, 0, len(mods))
for _, m := range mods {
if m != "text" {
out = append(out, m)
}
}
return out
}

if c.Modalities != nil {
enabled = append([]string{"text"}, nonText(*c.Modalities)...)
return enabled, nil
}

if c.MMProjArtifactID == 0 {
for _, m := range nonText(mdl.Modalities) {
unavailable = append(unavailable, ModalityGap{ID: m, Reason: "no mmproj linked"})
}
return []string{"text"}, unavailable
}

if a, ok := s.artifactByID[c.MMProjArtifactID]; ok && a.Missing {
for _, m := range nonText(mdl.Modalities) {
unavailable = append(unavailable, ModalityGap{ID: m, Reason: "mmproj file missing on disk"})
}
return []string{"text"}, unavailable
a, ok := s.artifactByID[c.MMProjArtifactID]
res := store.ResolveModalities(c, mdl, ok && a.Missing)
for _, m := range res.Unavailable {
unavailable = append(unavailable, ModalityGap{ID: m, Reason: res.Reason})
}

enabled = append([]string{"text"}, nonText(mdl.Modalities)...)
return enabled, nil
return res.Enabled, unavailable
}

// benchesFor unions a model's benchmarks (capability scores — intrinsic to
Expand Down
Loading
Loading