feat(agent): per-agent client↔agent access token for per-user authorization
HM now mints a random per-agent access token at deploy, injects it into the
agent env (AGENT_ACCESS_TOKEN + HEICODE_AGENT_ID) and returns it to the
deploying client (agent list access_token). Only the owning user receives it,
so only they can drive the agent — closing the gap where any valid sk- could
drive any agent and exfiltrate its mounted resources.
AM authorizes the caller either locally (compare to its env token) or via the
new public POST /api/heicode/agent-access/verify {agent_id, access_token} ->
{valid, user_id} (constant-time compare, no info leak on miss). AM may opt out.
Docs: AM contract §3.1 + client API §6 updated; access_token no longer empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -162,6 +163,18 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
}
|
||||
env["OPENAI_API_KEY"] = modelKey
|
||||
|
||||
// Per-agent client↔agent access token. HM mints a random secret, injects it
|
||||
// into the agent env (AGENT_ACCESS_TOKEN) AND returns it to the deploying
|
||||
// client (the agent list's access_token). Only the user who deployed this
|
||||
// agent receives the token, so only they can drive it: AM can verify the
|
||||
// caller's token == its own env AGENT_ACCESS_TOKEN locally (no HM round-trip),
|
||||
// or call POST /api/heicode/agent-access/verify to let HM be the authority.
|
||||
// AM may opt out of this entirely. HEICODE_AGENT_ID lets the agent name itself
|
||||
// when calling the verify endpoint.
|
||||
agentAccessToken := common.GetUUID()
|
||||
env["AGENT_ACCESS_TOKEN"] = agentAccessToken
|
||||
env["HEICODE_AGENT_ID"] = deploymentID
|
||||
|
||||
// Ask AM to start the agent with the template definition (.md) + env injected.
|
||||
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
|
||||
ManagerDeploymentID: deploymentID,
|
||||
@@ -185,7 +198,7 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateID: req.TemplateID,
|
||||
Subdomain: result.Subdomain,
|
||||
AccessToken: sealAgentToken(result.AccessToken),
|
||||
AccessToken: sealAgentToken(agentAccessToken),
|
||||
BindingIDsJSON: string(bindingIDsJSON),
|
||||
RuntimeDeploymentID: result.RuntimeID,
|
||||
Status: firstNonEmpty(result.Status, "running"),
|
||||
@@ -347,3 +360,52 @@ func HeicodeDeleteAgent(c *gin.Context) {
|
||||
"runtime_cleanup": runtimeCleanup, // "failed" => AM may still hold an orphan
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeVerifyAgentAccess: POST /api/heicode/agent-access/verify (public).
|
||||
//
|
||||
// The client↔agent access-control primitive HM provides for AM (optional). When
|
||||
// the desktop client connects to an agent's subdomain it presents the per-agent
|
||||
// access_token it got from the agent list. AM's agent can authorize the caller
|
||||
// in one of two ways:
|
||||
//
|
||||
// ① Local (no HM call): compare the caller's token to its own env
|
||||
// AGENT_ACCESS_TOKEN — they are the same secret HM injected at deploy.
|
||||
// ② Authoritative: POST here with {agent_id, access_token}. HM constant-time
|
||||
// compares against the stored token and returns {valid, user_id} so AM also
|
||||
// learns which HM user owns the agent.
|
||||
//
|
||||
// Only the user who deployed the agent ever receives its token, so a valid match
|
||||
// means "this user is allowed to call this agent". No user identity is leaked on
|
||||
// a miss. AM may ignore this entirely and roll its own scheme.
|
||||
func HeicodeVerifyAgentAccess(c *gin.Context) {
|
||||
var req struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
agentError(c, "INVALID_REQUEST", "invalid request body")
|
||||
return
|
||||
}
|
||||
req.AgentID = strings.TrimSpace(req.AgentID)
|
||||
req.AccessToken = strings.TrimSpace(req.AccessToken)
|
||||
if req.AgentID == "" || req.AccessToken == "" || model.DB == nil {
|
||||
common.ApiSuccess(c, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
var row model.AgentDeployment
|
||||
if err := model.DB.Where("deployment_id = ? AND template_id <> ''", req.AgentID).First(&row).Error; err != nil {
|
||||
common.ApiSuccess(c, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
stored := unsealAgentToken(row.AccessToken)
|
||||
if stored == "" || subtle.ConstantTimeCompare([]byte(stored), []byte(req.AccessToken)) != 1 {
|
||||
common.ApiSuccess(c, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"valid": true,
|
||||
"agent_id": row.DeploymentID,
|
||||
"user_id": row.UserID,
|
||||
"template_id": row.TemplateID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -539,6 +539,13 @@ func SetApiRouter(router *gin.Engine) {
|
||||
heicodeAgentRoute.DELETE("/agents/:deployment_id", controller.HeicodeDeleteAgent)
|
||||
}
|
||||
|
||||
// Client↔agent access control (HM-provided, AM-optional). Called by the
|
||||
// agent server-side to confirm the caller's per-agent access_token belongs
|
||||
// to this agent's owner, so only that user can drive it. Public on purpose
|
||||
// (the agent has no HM user session); it leaks nothing on a token miss and
|
||||
// the token itself is the bearer secret. Constant-time compared.
|
||||
apiRouter.POST("/heicode/agent-access/verify", controller.HeicodeVerifyAgentAccess)
|
||||
|
||||
// Agent template library (admin-maintained agent .md definitions; the
|
||||
// client list is served by the heicode group above, Chinese display).
|
||||
agentTemplateAdminRoute := apiRouter.Group("/agent-templates")
|
||||
|
||||
Reference in New Issue
Block a user