Files
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- FIND-SEC-01: GetUserContext requires session ownership (handler 403 +
  SQL/memory owner filter); in-memory HTTP + isolated-PG regressions
- FIND-ARCH-01: archtest fails closed on untracked table writes (+noise
  exemption) and registers 50 existing writer tables in table_owners
- FIND-SEC-02: Authorizer without pool denies by default; explicit
  NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition
- FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact
  basename allowlist match; prefix-spoof and no-shell-eval tests
- FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed
  hard-refuses validated_only steps (new error codes + HTTP mapping)
- FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
2026-09-26 02:00:14 +00:00

212 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/authz"
)
type intentPlanDecisionBody struct {
ExpectedRevision int64 `json:"expected_revision"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"`
Kind string `json:"kind"`
StepIDs []string `json:"step_ids"`
ExcludeUnselected bool `json:"exclude_unselected"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id"`
}
func (s *Server) handleGetActiveIntentPlan(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
view, err := plans.GetActivePlan(r.Context(), prin)
if err != nil {
writeIntentPlanError(w, r, plans, prin, "", err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func (s *Server) handleGetIntentPlan(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
planID := r.PathValue("plan_id")
view, err := plans.GetPlan(r.Context(), prin, planID)
if err != nil {
writeIntentPlanError(w, r, plans, prin, planID, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
var body intentPlanDecisionBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "PLAN_DEPENDENCY_INVALID",
"message": "无效的决策请求",
})
return
}
kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind))
planID := r.PathValue("plan_id")
view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{
Principal: prin,
PlanID: planID,
ExpectedRevision: body.ExpectedRevision,
DecisionNonce: body.DecisionNonce,
DecisionDigest: body.DecisionDigest,
Kind: kind,
StepIDs: body.StepIDs,
ExcludeUnselected: body.ExcludeUnselected,
})
if err != nil {
writeIntentPlanError(w, r, plans, prin, planID, err)
return
}
// After confirm, advance eligible steps via the existing plan ledger dispatcher
// (ClaimStep / ExecuteClaimed / RecoverReceipt). No second decision queue.
if shouldDispatchAfterDecision(kind, view) {
if advanced, advErr := plans.DispatchEligible(r.Context(), "http-confirm", planID); advErr == nil && advanced != nil {
view = advanced
}
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func shouldDispatchAfterDecision(kind intentflow.DecisionKind, view *intentflow.PlanView) bool {
if view == nil {
return false
}
switch kind {
case intentflow.DecisionConfirm, intentflow.DecisionConfirmRetry, intentflow.DecisionContinue:
return view.Status == intentflow.PlanExecuting ||
view.Status == intentflow.PlanPartiallyCompleted ||
view.Status == intentflow.PlanCompleted
default:
return false
}
}
func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) {
if s == nil || s.intentPlans() == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
"message": "计划服务不可用",
})
return nil, intentflow.Principal{}, false
}
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return nil, intentflow.Principal{}, false
}
projectID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("project_id")), 10, 64)
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if err != nil || projectID <= 0 || sessionID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "PLAN_DEPENDENCY_INVALID",
"message": "缺少 project_id 或 session_id",
})
return nil, intentflow.Principal{}, false
}
if err := s.planProjectAccess(r.Context(), projectID, user.ID); err != nil {
if errors.Is(err, authz.ErrUnauthenticated) {
writeJSONResponse(w, http.StatusUnauthorized, map[string]string{
"error_code": "ERR_UNAUTHENTICATED",
"message": "请先登录",
})
return nil, intentflow.Principal{}, false
}
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该项目",
})
return nil, intentflow.Principal{}, false
}
ownerID, ownerErr := s.planSessionOwner(r.Context(), sessionID)
if ownerErr != nil || ownerID <= 0 || ownerID != user.ID {
writeJSONResponse(w, http.StatusNotFound, map[string]string{
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
"message": "计划不存在或不可见",
})
return nil, intentflow.Principal{}, false
}
env := "dev"
if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" {
env = s.cfg.Env
}
return s.intentPlans(), intentflow.Principal{
UserID: user.ID,
Environment: env,
ProjectID: projectID,
SessionID: sessionID,
}, true
}
func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) error {
if s.planAccessFn != nil {
return s.planAccessFn(ctx, projectID, userID)
}
if s.authorizer() == nil {
return authz.ErrForbidden
}
return s.authorizer().RequireProjectAccess(ctx, projectID, userID)
}
func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) {
if s.planSessionOwnerFn != nil {
return s.planSessionOwnerFn(ctx, sessionID)
}
if s.ctxMgr() == nil {
return 0, intentflow.ErrPlanNotFoundOrHidden
}
return s.ctxMgr().GetSessionOwnerID(ctx, sessionID)
}
func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) {
code := intentflow.ErrorCode(err)
status := http.StatusInternalServerError
switch {
case errors.Is(err, intentflow.ErrUnauthenticated):
status = http.StatusUnauthorized
case errors.Is(err, intentflow.ErrPlanNotFoundOrHidden):
status = http.StatusNotFound
case errors.Is(err, intentflow.ErrPlanActionForbidden):
status = http.StatusForbidden
case errors.Is(err, intentflow.ErrPlanModeNotConfirmable),
errors.Is(err, intentflow.ErrPlanModeNotExecutable):
// validated_only(shadow/dry-run)计划的硬边界:该动作对这类计划永远不可用。
status = http.StatusForbidden
case errors.Is(err, intentflow.ErrPlanStaleRevision),
errors.Is(err, intentflow.ErrPlanExpired),
errors.Is(err, intentflow.ErrPlanDecisionConflict),
errors.Is(err, intentflow.ErrPlanActionVersionConflict):
status = http.StatusConflict
case errors.Is(err, intentflow.ErrPlanDependencyInvalid):
status = http.StatusBadRequest
}
body := map[string]any{"error_code": code, "message": code}
if plans != nil && (errors.Is(err, intentflow.ErrPlanStaleRevision) || errors.Is(err, intentflow.ErrPlanDecisionConflict)) && planID != "" {
if latest, getErr := plans.GetPlan(r.Context(), prin, planID); getErr == nil && latest != nil {
body["plan"] = latest
}
}
writeJSONResponse(w, status, body)
}