fix(ai): external responder on its own listener with random turn ids and reply tokens

보안 리뷰 반영. 외부 응답은 곧 진단이고 자동 승인 아래서는 실행 조치가 되므로,
/external/next·/external/reply 를 에이전트 포트에서 떼어 EXTERNAL_ADDR 리스너(기본 꺼짐,
VM compose 는 127.0.0.1:8090 에만 매핑해 IAP SSH 터널로만 도달)로 옮기고, 턴 id 를 난수로
만들며, 응답마다 그 턴을 가져간 클라이언트만 아는 reply_token 을 요구해 상수 시간으로
비교한다. /selfcheck 는 리스너가 꺼져 있으면 external_responder: "off" 를 보인다.
This commit is contained in:
seon committed 2026-10-16 09:09:22 +09:00
1 parent ed3d41d399
commit b0ff118841
3 files changed
+67 -28

No files matched your search

+37 -6
View File
@@ -3,10 +3,18 @@
// GET /external/next (long poll); the client answers with POST /external/reply. Used for reference measurements with
// a model that has no API path (2026-10-16: Claude Mythos 5.1 driven by a Claude Code subagent). Token usage is not
// known for such turns and is reported as zero; reports mark those runs as reference records.
//
// A reply becomes a diagnosis and, with auto-approval, an executed action, so whoever can answer a turn controls the
// lab: the endpoints are served on their own listener (EXTERNAL_ADDR, off by default; the VM maps it to 127.0.0.1 so
// only the IAP SSH tunnel reaches it), turn ids are random, and every reply must carry the per-turn reply_token that
// only the client who took the turn received.
package external
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -24,7 +32,8 @@ const maxConvCached = 64
// on the first turn, so it is carried along), the tool definitions and whether the final submit_diagnosis is forced.
type Turn struct {
TurnID string `json:"turn_id"`
Session string `json:"session"` // first response id of the conversation ("" on the first turn)
ReplyToken string `json:"reply_token"` // random, handed out with the turn, required by /external/reply
Session string `json:"session"` // first response id of the conversation ("" on the first turn)
Model string `json:"model"`
Turn int `json:"turn"` // 1-based index within the conversation
System string `json:"system"`
@@ -38,9 +47,10 @@ type Turn struct {
// Reply is the client's answer: function calls (name + arguments) and/or assistant text.
type Reply struct {
TurnID string `json:"turn_id"`
Output []replyItem `json:"output"`
Error string `json:"error,omitempty"` // client gave up on this turn
TurnID string `json:"turn_id"`
ReplyToken string `json:"reply_token"`
Output []replyItem `json:"output"`
Error string `json:"error,omitempty"` // client gave up on this turn
}
type replyItem struct {
@@ -115,11 +125,11 @@ func (r *Responder) CreateResponse(ctx context.Context, req openai.Request) (*op
if m, ok := req.ToolChoice.(map[string]any); ok && m["type"] == "function" {
forced = true
}
id, tok := "ext_"+randHex(8), randHex(16)
r.mu.Lock()
r.seq++
id := fmt.Sprintf("ext_%d", r.seq)
now := time.Now()
p := &pending{turn: Turn{TurnID: id, Session: hist.first, Model: req.Model, Turn: hist.turn + 1, System: hist.system, Input: hist.items, Tools: req.Tools,
p := &pending{turn: Turn{TurnID: id, ReplyToken: tok, Session: hist.first, Model: req.Model, Turn: hist.turn + 1, System: hist.system, Input: hist.items, Tools: req.Tools,
ForceSubmit: forced, MaxOutputTokens: req.MaxOutputTokens, IssuedAt: now, DeadlineAt: now.Add(r.TurnTimeout)}, reply: make(chan Reply, 1)}
r.queue = append(r.queue, p)
r.lastTurn = now
@@ -269,6 +279,11 @@ func (r *Responder) ReplyHandler(w http.ResponseWriter, req *http.Request) {
}
r.mu.Lock()
p, ok := r.taken[rep.TurnID]
if ok && subtle.ConstantTimeCompare([]byte(p.turn.ReplyToken), []byte(rep.ReplyToken)) != 1 {
r.mu.Unlock()
writeJSON(w, 403, map[string]string{"error": "reply_token does not match turn " + rep.TurnID})
return
}
if ok {
delete(r.taken, rep.TurnID)
}
@@ -295,6 +310,22 @@ func (r *Responder) Status() map[string]any {
return out
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand: " + err.Error())
}
return hex.EncodeToString(b)
}
// Handler serves the two endpoints; mount it on the dedicated external listener only.
func (r *Responder) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /external/next", r.Next)
mux.HandleFunc("POST /external/reply", r.ReplyHandler)
return mux
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)