From 87184143becc73dd3a815a42efa23325575710e9 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:32:17 +0530 Subject: [PATCH 01/11] feat(agent-vault): pick the Agent Vault project and add sessions create/revoke Agent Vault orgs can now hold several projects. Commands that mint sessions or push skills take --project-id, INFISICAL_AGENT_VAULT_PROJECT_ID, the agent-vault.project-id setting in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order. New sessions create and sessions revoke (by ID or --session-token). --- packages/api/agent_vault.go | 55 ++++- packages/api/agent_vault_test.go | 42 ++++ packages/cmd/agent_vault_project.go | 133 +++++++++++ packages/cmd/agent_vault_project_test.go | 170 ++++++++++++++ packages/cmd/agent_vault_run.go | 82 ++++--- packages/cmd/agent_vault_sessions.go | 263 ++++++++++++++++++++++ packages/cmd/agent_vault_sessions_test.go | 176 +++++++++++++++ packages/cmd/agent_vault_skills.go | 25 +- packages/cmd/agent_vault_skills_test.go | 38 ++-- packages/cmd/profile.go | 1 + packages/models/cli.go | 6 + packages/util/config.go | 1 + packages/util/config_test.go | 25 ++ 13 files changed, 957 insertions(+), 60 deletions(-) create mode 100644 packages/cmd/agent_vault_project.go create mode 100644 packages/cmd/agent_vault_project_test.go create mode 100644 packages/cmd/agent_vault_sessions.go create mode 100644 packages/cmd/agent_vault_sessions_test.go diff --git a/packages/api/agent_vault.go b/packages/api/agent_vault.go index 033bf2086..a8b500f21 100644 --- a/packages/api/agent_vault.go +++ b/packages/api/agent_vault.go @@ -240,15 +240,25 @@ func CallCreateAgentVaultSessionLogChunk(ctx context.Context, httpClient *resty. } type CreateAgentVaultSessionRequest struct { + ProjectID string `json:"projectId,omitempty"` AccessBundles []string `json:"accessBundles"` TTL string `json:"ttl"` Label string `json:"label,omitempty"` } +type AgentVaultSessionAccessBundle struct { + ID string `json:"id"` + Name string `json:"name"` +} + type AgentVaultSession struct { - ID string `json:"id"` - Token string `json:"token"` - ExpiresAt *string `json:"expiresAt"` + ID string `json:"id"` + Token string `json:"token"` + ProjectID string `json:"projectId"` + Label *string `json:"label"` + AccessBundles []AgentVaultSessionAccessBundle `json:"accessBundles"` + ExpiresAt *string `json:"expiresAt"` + CreatedAt string `json:"createdAt"` } type CreateAgentVaultSessionResponse struct { @@ -289,6 +299,33 @@ func CallRevokeAgentVaultSession(ctx context.Context, httpClient *resty.Client, return nil } +type RevokeAgentVaultSessionByTokenResponse struct { + Session struct { + ID string `json:"id"` + RevokedAt *string `json:"revokedAt"` + } `json:"session"` +} + +// The session token is the only credential: whoever holds it may end the session it names. +func CallRevokeAgentVaultSessionByToken(ctx context.Context, httpClient *resty.Client, sessionToken string) (RevokeAgentVaultSessionByTokenResponse, error) { + var res RevokeAgentVaultSessionByTokenResponse + response, err := httpClient. + R(). + SetContext(ctx). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetAuthToken(sessionToken). + Post(fmt.Sprintf("%v/v1/agent-vault/session/revoke", config.INFISICAL_URL)) + + if err != nil { + return RevokeAgentVaultSessionByTokenResponse{}, NewGenericRequestError("CallRevokeAgentVaultSessionByToken", err) + } + if response.IsError() { + return RevokeAgentVaultSessionByTokenResponse{}, NewAPIErrorWithResponse("CallRevokeAgentVaultSessionByToken", response, nil) + } + return res, nil +} + type AgentVaultSkillFile struct { Path string `json:"path"` Mode string `json:"mode"` @@ -353,12 +390,13 @@ type ListAgentVaultSkillsResponse struct { TotalCount int `json:"totalCount"` } -func CallListAgentVaultSkillsByName(httpClient *resty.Client, name string) (ListAgentVaultSkillsResponse, error) { +func CallListAgentVaultSkillsByName(httpClient *resty.Client, projectID, name string) (ListAgentVaultSkillsResponse, error) { var res ListAgentVaultSkillsResponse response, err := httpClient. R(). SetResult(&res). SetHeader("User-Agent", USER_AGENT). + SetQueryParam("projectId", projectID). SetQueryParam("name", name). Get(fmt.Sprintf("%v/v1/agent-vault/skills", config.INFISICAL_URL)) @@ -375,12 +413,19 @@ type AgentVaultSkillFilesRequest struct { Files []AgentVaultSkillFile `json:"files"` } +// Separate from AgentVaultSkillFilesRequest because a publish names its skill by ID and the server takes the +// project from that skill. +type CreateAgentVaultSkillRequest struct { + ProjectID string `json:"projectId"` + Files []AgentVaultSkillFile `json:"files"` +} + type CreateAgentVaultSkillResponse struct { Skill AgentVaultSkill `json:"skill"` Version AgentVaultSkillVersion `json:"version"` } -func CallCreateAgentVaultSkill(httpClient *resty.Client, request AgentVaultSkillFilesRequest) (CreateAgentVaultSkillResponse, error) { +func CallCreateAgentVaultSkill(httpClient *resty.Client, request CreateAgentVaultSkillRequest) (CreateAgentVaultSkillResponse, error) { var res CreateAgentVaultSkillResponse response, err := httpClient. R(). diff --git a/packages/api/agent_vault_test.go b/packages/api/agent_vault_test.go index e9b2fd570..fc1f44d78 100644 --- a/packages/api/agent_vault_test.go +++ b/packages/api/agent_vault_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -74,3 +75,44 @@ func TestCreateAgentVaultSessionRequestSendsALabelOnlyWhenGiven(t *testing.T) { t.Errorf("want the label in the body, got %s", labeled) } } + +func TestCreateAgentVaultSessionRequestSendsTheProject(t *testing.T) { + body, err := json.Marshal(CreateAgentVaultSessionRequest{ProjectID: "p1", AccessBundles: []string{"coding-agent"}, TTL: "never"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"projectId":"p1"`) { + t.Errorf("want the project in the body, got %s", body) + } +} + +func TestOnlyASkillCreateSendsTheProject(t *testing.T) { + created, _ := json.Marshal(CreateAgentVaultSkillRequest{ProjectID: "p1"}) + published, _ := json.Marshal(AgentVaultSkillFilesRequest{}) + if !strings.Contains(string(created), `"projectId":"p1"`) || strings.Contains(string(published), "projectId") { + t.Fatalf("create %s, publish %s", created, published) + } +} + +func TestRevokeAgentVaultSessionByTokenUsesNoOtherCredential(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"statusCode":401,"message":"Invalid session token"}`)) + })) + defer srv.Close() + old := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + t.Cleanup(func() { config.INFISICAL_URL = old }) + + _, err := CallRevokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("err %v, want a 401", err) + } + if auth != "Bearer agv_x" { + t.Fatalf("Authorization %q", auth) + } +} diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go new file mode 100644 index 000000000..28d685d6d --- /dev/null +++ b/packages/cmd/agent_vault_project.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +const agentVaultProjectIDEnv = "INFISICAL_AGENT_VAULT_PROJECT_ID" + +type agentVaultProject struct { + ID string + // The flag, the variable or the file the ID came from, as messages name it. + Source string + FromFlag bool +} + +// resolveAgentVaultProject picks the Agent Vault project from --project-id, then INFISICAL_AGENT_VAULT_PROJECT_ID, +// then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID. The last is shared with Secrets +// Management, so it comes after everything that can only name an Agent Vault project. +func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { + if cmd.Flags().Changed("project-id") { + value, err := cmd.Flags().GetString("project-id") + if err != nil { + return agentVaultProject{}, fmt.Errorf("unable to read --project-id: %w", err) + } + // Same reasoning as the empty --session-token: "$PROJECT" with the variable unset should not fall + // through to a project the operator did not name. + if strings.TrimSpace(value) == "" { + return agentVaultProject{}, fmt.Errorf("--project-id was given but is empty; pass the Agent Vault project's ID, or drop the flag and set %s", agentVaultProjectIDEnv) + } + project, err := agentVaultProjectFrom(value, "--project-id") + project.FromFlag = true + return project, err + } + + project, found, err := resolveAgentVaultProjectFromEnvironment() + if err != nil { + return agentVaultProject{}, err + } + if !found { + return agentVaultProject{}, fmt.Errorf( + "no Agent Vault project is set. Pass --project-id, set %s, add agent-vault.project-id to %s, or set %s", + agentVaultProjectIDEnv, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME, util.INFISICAL_PROJECT_ID_NAME) + } + return project, nil +} + +// resolveAgentVaultProjectFromEnvironment is every source but the flag, for commands that have none. +func resolveAgentVaultProjectFromEnvironment() (project agentVaultProject, found bool, err error) { + if value := strings.TrimSpace(os.Getenv(agentVaultProjectIDEnv)); value != "" { + project, err = agentVaultProjectFrom(value, agentVaultProjectIDEnv) + return project, true, err + } + + if path, findErr := util.FindWorkspaceConfigFile(); findErr == nil { + workspace, readErr := util.GetWorkSpaceFromFilePath(filepath.Dir(path)) + if readErr != nil { + return agentVaultProject{}, false, fmt.Errorf("unable to read %s: %w", path, readErr) + } + if value := strings.TrimSpace(workspace.AgentVaultProjectID); value != "" { + project, err = agentVaultProjectFrom(value, path) + return project, true, err + } + } + + if value := strings.TrimSpace(os.Getenv(util.INFISICAL_PROJECT_ID_NAME)); value != "" { + project, err = agentVaultProjectFrom(value, util.INFISICAL_PROJECT_ID_NAME) + return project, true, err + } + return agentVaultProject{}, false, nil +} + +func agentVaultProjectFrom(value, source string) (agentVaultProject, error) { + value = strings.TrimSpace(value) + // uuid.Parse also takes the braced and urn: spellings, which the server would refuse. + if _, err := uuid.Parse(value); err != nil || len(value) != 36 { + return agentVaultProject{}, fmt.Errorf("%q from %s is not a project ID; copy the ID from the Agent Vault project's settings in Infisical", value, source) + } + return agentVaultProject{ID: value, Source: source}, nil +} + +// A flag was typed for this run; anything else is ambient, so the command says which project it picked up. +func echoAgentVaultProject(project agentVaultProject) { + if !project.FromFlag { + util.PrintlnStderr(fmt.Sprintf("Agent Vault project %s from %s", project.ID, project.Source)) + } +} + +// INFISICAL_PROJECT_ID is often already set for Secrets Management, and the server answers 404 for a project +// that is not an Agent Vault one. +func agentVaultProjectHints(err error, project agentVaultProject) []string { + var apiErr *api.APIError + if project.Source != util.INFISICAL_PROJECT_ID_NAME || !errors.As(err, &apiErr) || + apiErr.StatusCode != http.StatusNotFound || apiErr.DocumentationURL != "" { + return nil + } + return []string{fmt.Sprintf( + "The project came from %s, which may name a Secrets Management project. Set %s or pass --project-id with the Agent Vault project's ID.", + util.INFISICAL_PROJECT_ID_NAME, agentVaultProjectIDEnv)} +} + +func agentVaultProjectSourceKind(project agentVaultProject) string { + switch project.Source { + case "--project-id": + return "flag" + case agentVaultProjectIDEnv: + return "agent-vault-env" + case util.INFISICAL_PROJECT_ID_NAME: + return "project-env" + default: + return "config-file" + } +} + +func printAgentVaultProjectForProfile() { + project, found, err := resolveAgentVaultProjectFromEnvironment() + switch { + case err != nil: + util.PrintlnStdout("Agent Vault project:", err.Error()) + case found: + util.PrintlnStdout("Agent Vault project:", project.ID) + util.PrintlnStdout("Agent Vault project via:", project.Source) + } +} diff --git a/packages/cmd/agent_vault_project_test.go b/packages/cmd/agent_vault_project_test.go new file mode 100644 index 000000000..6159310d5 --- /dev/null +++ b/packages/cmd/agent_vault_project_test.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" +) + +const ( + projectFromFlag = "11111111-1111-4111-8111-111111111111" + projectFromAgvEnv = "22222222-2222-4222-8222-222222222222" + projectFromFile = "33333333-3333-4333-8333-333333333333" + projectFromProjEnv = "44444444-4444-4444-8444-444444444444" +) + +func projectIDCmd(t *testing.T, args ...string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("project-id", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd +} + +// isolateAgentVaultProject clears every source and moves into an empty folder, so no real .infisical.yaml is read. +func isolateAgentVaultProject(t *testing.T) string { + t.Helper() + t.Setenv(agentVaultProjectIDEnv, "") + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "") + dir := t.TempDir() + t.Chdir(dir) + return dir +} + +func writeAgentVaultWorkspaceFile(t *testing.T, dir, projectID string) string { + t.Helper() + path := filepath.Join(dir, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) + content := "version: 1\nsecrets-management:\n project-id: 99999999-9999-4999-8999-999999999999\nagent-vault:\n project-id: " + projectID + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestResolveAgentVaultProjectOrder(t *testing.T) { + dir := isolateAgentVaultProject(t) + path := writeAgentVaultWorkspaceFile(t, dir, projectFromFile) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) + + steps := []struct { + args []string + wantID string + wantSource string + drop func() + }{ + {[]string{"--project-id", projectFromFlag}, projectFromFlag, "--project-id", nil}, + {nil, projectFromAgvEnv, agentVaultProjectIDEnv, func() { t.Setenv(agentVaultProjectIDEnv, "") }}, + {nil, projectFromFile, path, func() { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + }}, + {nil, projectFromProjEnv, util.INFISICAL_PROJECT_ID_NAME, nil}, + } + for _, step := range steps { + project, err := resolveAgentVaultProject(projectIDCmd(t, step.args...)) + if err != nil { + t.Fatal(err) + } + if project.ID != step.wantID || !sameAgentVaultProjectSource(project.Source, step.wantSource) { + t.Fatalf("got %s from %s, want %s from %s", project.ID, project.Source, step.wantID, step.wantSource) + } + if project.FromFlag != (step.wantSource == "--project-id") { + t.Fatalf("FromFlag = %v for %s", project.FromFlag, project.Source) + } + if step.drop != nil { + step.drop() + } + } +} + +// The temp folder is reached through a symlink on macOS, so a path source may name it either way. +func sameAgentVaultProjectSource(got, want string) bool { + if got == want { + return true + } + gotPath, gotErr := filepath.EvalSymlinks(got) + wantPath, wantErr := filepath.EvalSymlinks(want) + return gotErr == nil && wantErr == nil && gotPath == wantPath +} + +func TestResolveAgentVaultProjectRefusesAnEmptyFlag(t *testing.T) { + isolateAgentVaultProject(t) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + + _, err := resolveAgentVaultProject(projectIDCmd(t, "--project-id", " ")) + if err == nil || !strings.Contains(err.Error(), "--project-id was given but is empty") { + t.Fatalf("an explicitly empty --project-id should be an error, not a fall back to the environment: %v", err) + } +} + +func TestResolveAgentVaultProjectRefusesANonUuidNamingItsSource(t *testing.T) { + dir := isolateAgentVaultProject(t) + + if _, err := resolveAgentVaultProject(projectIDCmd(t, "--project-id", "my-project")); err == nil || !strings.Contains(err.Error(), `"my-project" from --project-id`) { + t.Fatalf("flag: %v", err) + } + + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "{"+projectFromProjEnv+"}") + if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), "from "+util.INFISICAL_PROJECT_ID_NAME) { + t.Fatalf("braced uuid from %s: %v", util.INFISICAL_PROJECT_ID_NAME, err) + } + + writeAgentVaultWorkspaceFile(t, dir, "nope") + if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) { + t.Fatalf("file: %v", err) + } +} + +func TestResolveAgentVaultProjectWithNothingSetNamesEverySource(t *testing.T) { + isolateAgentVaultProject(t) + + _, err := resolveAgentVaultProject(projectIDCmd(t)) + if err == nil { + t.Fatal("want an error when no project is set") + } + for _, source := range []string{"--project-id", agentVaultProjectIDEnv, "agent-vault.project-id", util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME, util.INFISICAL_PROJECT_ID_NAME} { + if !strings.Contains(err.Error(), source) { + t.Errorf("the message should name %s: %q", source, err) + } + } +} + +func TestResolveAgentVaultProjectSkipsAFileWithoutAnAgentVaultSection(t *testing.T) { + dir := isolateAgentVaultProject(t) + if err := os.WriteFile(filepath.Join(dir, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte("version: 1\nsecrets-management:\n project-id: abc\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) + + project, err := resolveAgentVaultProject(projectIDCmd(t)) + if err != nil || project.ID != projectFromProjEnv { + t.Fatalf("got %+v, %v", project, err) + } +} + +func TestAgentVaultProjectHintsOnlyFor404FromTheSharedVariable(t *testing.T) { + notFound := &api.APIError{StatusCode: 404} + shared := agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME} + + if hints := agentVaultProjectHints(notFound, shared); len(hints) != 1 || !strings.Contains(hints[0], "Secrets Management") || !strings.Contains(hints[0], agentVaultProjectIDEnv) { + t.Fatalf("hints %v", hints) + } + if hints := agentVaultProjectHints(notFound, agentVaultProject{Source: agentVaultProjectIDEnv}); hints != nil { + t.Fatalf("a project from %s needs no hint: %v", agentVaultProjectIDEnv, hints) + } + if hints := agentVaultProjectHints(&api.APIError{StatusCode: 403}, shared); hints != nil { + t.Fatalf("a 403 needs no hint: %v", hints) + } + if hints := agentVaultProjectHints(&api.APIError{StatusCode: 404, DocumentationURL: "https://infisical.com/docs"}, shared); hints != nil { + t.Fatalf("a missing route is not a missing project: %v", hints) + } +} diff --git a/packages/cmd/agent_vault_run.go b/packages/cmd/agent_vault_run.go index 5f88aa58b..553f2eb3c 100644 --- a/packages/cmd/agent_vault_run.go +++ b/packages/cmd/agent_vault_run.go @@ -113,7 +113,9 @@ Two ways to get a session, exactly one of them required: --access-bundle mint one now over the named bundle, or set INFISICAL_AGENT_VAULT_ACCESS_BUNDLE (needs your login or a machine identity). Revoked when the agent exits unless - --keep-session is set. + --keep-session is set. The bundle is looked up in the Agent Vault project from + --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, agent-vault.project-id + in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order. --session-token run with a session token minted in the dashboard, or set INFISICAL_AGENT_VAULT_SESSION_TOKEN. Never revoked by this command. @@ -260,6 +262,27 @@ func resolveAgentVaultSessionLabel(cmd *cobra.Command, sessionToken string, toke return strings.TrimSpace(label), nil } +// None of these can reach a session minted in the dashboard: its expiry and project were fixed when it was +// created, and this command never revokes it. +func refuseAgentVaultMintOnlyFlags(cmd *cobra.Command, sessionToken string, tokenSource agentVaultSource) error { + if sessionToken == "" { + return nil + } + tokenName := agentVaultSourceName(tokenSource, "--session-token", agentVaultSessionTokenEnv) + switch { + case cmd.Flags().Changed("ttl"): + return fmt.Errorf( + "--ttl applies to a session this command mints; the one behind %s was given its lifetime in the dashboard. Drop --ttl, or mint here with --access-bundle", tokenName) + case cmd.Flags().Changed("keep-session"): + return fmt.Errorf( + "--keep-session applies to a session this command mints; the one behind %s is never revoked here. Drop --keep-session, or mint here with --access-bundle", tokenName) + case cmd.Flags().Changed("project-id"): + return fmt.Errorf( + "--project-id applies to a session this command mints; the one behind %s already belongs to a project. Drop --project-id, or mint here with --access-bundle", tokenName) + } + return nil +} + func runAgentVaultRun(cmd *cobra.Command, args []string) { sessionToken, tokenSource, err := resolveAgentVaultSessionToken(cmd) if err != nil { @@ -287,14 +310,8 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { tokenSource = agentVaultSourceNone } - if len(accessBundles) > 1 { - util.HandleError(fmt.Errorf("a session carries one access bundle; pass --access-bundle once")) - } - for _, name := range accessBundles { - if !agentVaultBundleNameRe.MatchString(name) { - util.HandleError(fmt.Errorf("%q from %s is not an access bundle name; names are lowercase letters, numbers and hyphens, for example 'coding-agent'", - name, agentVaultSourceName(bundleSource, "--access-bundle", agentVaultAccessBundleEnv))) - } + if err := validateAgentVaultAccessBundles(accessBundles, bundleSource); err != nil { + util.HandleError(err) } skillsDir, noSkills, err := resolveAgentVaultRunSkills(cmd) @@ -308,16 +325,14 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { util.HandleError(err) } - // Neither flag can reach a session minted in the dashboard: its expiry was fixed when it was created. - if sessionToken != "" { - tokenName := agentVaultSourceName(tokenSource, "--session-token", agentVaultSessionTokenEnv) - if cmd.Flags().Changed("ttl") { - util.HandleError(fmt.Errorf( - "--ttl applies to a session this command mints; the one behind %s was given its lifetime in the dashboard. Drop --ttl, or mint here with --access-bundle", tokenName)) - } - if cmd.Flags().Changed("keep-session") { - util.HandleError(fmt.Errorf( - "--keep-session applies to a session this command mints; the one behind %s is never revoked here. Drop --keep-session, or mint here with --access-bundle", tokenName)) + if err := refuseAgentVaultMintOnlyFlags(cmd, sessionToken, tokenSource); err != nil { + util.HandleError(err) + } + + var project agentVaultProject + if len(accessBundles) > 0 { + if project, err = resolveAgentVaultProject(cmd); err != nil { + util.HandleError(err) } } @@ -413,18 +428,8 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { var minted *api.AgentVaultSession if len(accessBundles) > 0 { identity := resolveAgentVaultIdentityToken(cmd) - httpClient, clientErr := util.GetRestyClientWithCustomHeaders() - if clientErr != nil { - util.HandleError(clientErr, "Failed to build the API client") - } - httpClient.SetAuthToken(identity) - - created, mintErr := api.CallCreateAgentVaultSession(httpClient, api.CreateAgentVaultSessionRequest{AccessBundles: accessBundles, TTL: ttl, Label: label}) - if mintErr != nil { - util.HandleError(mintErr, "Unable to mint an Agent Vault session") - } - minted = &created.Session - sessionToken = created.Session.Token + minted = mintAgentVaultSessionOrExit(identity, project, accessBundles, ttl, label) + sessionToken = minted.Token Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) } @@ -441,7 +446,7 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { env := buildAgentVaultRunEnv(os.Environ(), proxyAddr, sessionToken, caPath, extraNoProxy) - printAgentVaultRunSummary(proxyAddr, caResp, servedFingerprint, minted, tokenSource, bundleSource) + printAgentVaultRunSummary(proxyAddr, caResp, servedFingerprint, minted, project, tokenSource, bundleSource) if exitCode, interrupted := runAgentVaultRunSkillsStep(noSkills, func(ctx context.Context) { @@ -633,6 +638,12 @@ func revocationToken(ctx context.Context, cmd *cobra.Command, loginPolicy util.R } func resolveAgentVaultIdentityToken(cmd *cobra.Command) string { + return resolveAgentVaultIdentity(cmd, true) +} + +// Without the wizard, an expired or missing login is an error rather than an interactive sign-in, for a command +// whose output is being captured and so has nobody watching its prompts. +func resolveAgentVaultIdentity(cmd *cobra.Command, allowLoginWizard bool) string { clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") if clientID != "" && clientSecret != "" { @@ -652,6 +663,9 @@ func resolveAgentVaultIdentityToken(cmd *cobra.Command) string { details, err := util.GetCurrentLoggedInUserDetails(true) if err != nil || !details.IsUserLoggedIn || details.LoginExpired { + if !allowLoginWizard { + util.HandleError(fmt.Errorf("your Infisical login has expired or is missing, and this command's output is being captured, so it can't sign you in. Run 'infisical login' first, set %s, or pass --client-id and --client-secret", util.INFISICAL_TOKEN_NAME)) + } details = util.EstablishUserLoginSession() } if details.UserCredentials.JTWToken == "" { @@ -788,7 +802,7 @@ func agentVaultFingerprintsEqual(a, b string) bool { return normalize(a) != "" && normalize(a) == normalize(b) } -func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerprint string, minted *api.AgentVaultSession, tokenSource, bundleSource agentVaultSource) { +func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerprint string, minted *api.AgentVaultSession, project agentVaultProject, tokenSource, bundleSource agentVaultSource) { dim := color.HiBlackString fmt.Fprintln(os.Stderr, color.GreenString("Starting agent behind Agent Vault proxy %q at %s", ca.Name, proxyAddr)) fmt.Fprintln(os.Stderr, dim("proxy CA fingerprint: "+fingerprint)) @@ -800,6 +814,7 @@ func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerpri fmt.Fprintln(os.Stderr, dim("access bundle from "+agentVaultAccessBundleEnv)) } if minted != nil { + fmt.Fprintln(os.Stderr, dim(fmt.Sprintf("Agent Vault project %s from %s", project.ID, project.Source))) expiry := "never" if minted.ExpiresAt != nil { expiry = *minted.ExpiresAt @@ -841,6 +856,7 @@ func init() { agentVaultRunCmd.Flags().String("session-token", "", "run with a session token minted in the dashboard instead of minting one (falls back to "+agentVaultSessionTokenEnv+")") agentVaultRunCmd.Flags().String("ttl", "never", "lifetime of the session this command creates: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") agentVaultRunCmd.Flags().Bool("keep-session", false, "leave a minted session active when the agent exits") + agentVaultRunCmd.Flags().String("project-id", "", "Agent Vault project of the access bundle to mint over (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") agentVaultRunCmd.Flags().String("label", "", "describe what the session this command creates is for (up to 128 characters)") agentVaultRunCmd.Flags().String("proxy", "", "address of the Agent Vault proxy as host:port (falls back to "+agentVaultProxyAddressEnv+")") agentVaultRunCmd.Flags().String("ca-fingerprint", "", "abort unless the proxy's certificate authority matches this SHA256 fingerprint from Infisical") diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go new file mode 100644 index 000000000..af234333f --- /dev/null +++ b/packages/cmd/agent_vault_sessions.go @@ -0,0 +1,263 @@ +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "net/http" + "os" + "runtime" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/telemetry" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/google/uuid" + "github.com/mattn/go-isatty" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +var agentVaultSessionsCmd = &cobra.Command{ + Use: "sessions", + Short: "Create and revoke Agent Vault sessions", +} + +var agentVaultSessionsCreateCmd = &cobra.Command{ + Use: "create --access-bundle ", + Short: "Create a session over an access bundle and print its token", + Long: `Create a session over an access bundle and print its token. + +The session belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, +agent-vault.project-id in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order. + +Only the token is printed, so it can be captured into a variable. Pass -o json or -o yaml for the session's +ID, project, access bundle, label and expiry as well. The session stays active until it expires or you revoke +it with 'infisical agent-vault sessions revoke'.`, + Example: ` export INFISICAL_AGENT_VAULT_SESSION_TOKEN=$(infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h) + infisical agent-vault sessions create --project-id 4f0c... --access-bundle coding-agent --label "nightly deploy" -o json`, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: runAgentVaultSessionsCreate, +} + +type agentVaultSessionOutput struct { + ID string `json:"id"` + Token string `json:"token"` + ProjectID string `json:"projectId"` + AccessBundle string `json:"accessBundle"` + Label *string `json:"label"` + ExpiresAt *string `json:"expiresAt"` +} + +func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to read --output") + } + // Checked before the session exists, so a typo never leaves one behind with its token unprinted. + if outputFormat != "" && outputFormat != util.OUTPUT_FORMAT_JSON && outputFormat != util.OUTPUT_FORMAT_YAML { + util.HandleError(fmt.Errorf("--output must be json or yaml, got %q; leave it out to print only the token", outputFormat)) + } + + accessBundles, bundleSource, err := resolveAgentVaultAccessBundles(cmd) + if err != nil { + util.HandleError(err) + } + if bundleSource == agentVaultSourceNone { + util.HandleError(fmt.Errorf("an access bundle is required; pass --access-bundle or set %s", agentVaultAccessBundleEnv)) + } + if err := validateAgentVaultAccessBundles(accessBundles, bundleSource); err != nil { + util.HandleError(err) + } + + project, err := resolveAgentVaultProject(cmd) + if err != nil { + util.HandleError(err) + } + echoAgentVaultProject(project) + + ttl, _ := cmd.Flags().GetString("ttl") + label, _ := cmd.Flags().GetString("label") + label = strings.TrimSpace(label) + + stdoutIsTerminal := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + identity := resolveAgentVaultIdentity(cmd, stdoutIsTerminal) + minted := mintAgentVaultSessionOrExit(identity, project, accessBundles, ttl, label) + + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) + Telemetry.CaptureEvent("cli-command:agent-vault sessions create", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("platform", runtime.GOOS). + Set("ttl", ttl). + Set("labeled", label != ""). + Set("projectSource", agentVaultProjectSourceKind(project)). + Set("output", outputFormat)) + + if outputFormat == "" { + fmt.Println(minted.Token) + return + } + formatted, err := util.FormatOutput(outputFormat, agentVaultSessionOutputFrom(*minted, project, accessBundles[0]), nil) + if err != nil { + util.HandleError(err, "Unable to format the session") + } + fmt.Println(strings.TrimRight(formatted, "\n")) +} + +func agentVaultSessionOutputFrom(minted api.AgentVaultSession, project agentVaultProject, accessBundle string) agentVaultSessionOutput { + out := agentVaultSessionOutput{ + ID: minted.ID, + Token: minted.Token, + ProjectID: cmp.Or(minted.ProjectID, project.ID), + AccessBundle: accessBundle, + Label: minted.Label, + ExpiresAt: minted.ExpiresAt, + } + if len(minted.AccessBundles) > 0 { + out.AccessBundle = minted.AccessBundles[0].Name + } + return out +} + +var agentVaultSessionsRevokeCmd = &cobra.Command{ + Use: "revoke [session-id]", + Short: "Revoke a session by its ID or its token", + Long: `Revoke a session by its ID or its token. + +By ID, the session is revoked as you, or as the machine identity from --client-id and --client-secret, which +needs access to it. By --session-token, the session token alone is enough: whoever holds it can end the session. +Revoking a session that has already ended succeeds.`, + Example: ` infisical agent-vault sessions revoke 0b6f3a1e-... + infisical agent-vault sessions revoke --session-token "$INFISICAL_AGENT_VAULT_SESSION_TOKEN"`, + DisableFlagsInUseLine: true, + Args: cobra.MaximumNArgs(1), + Run: runAgentVaultSessionsRevoke, +} + +func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { + token, _ := cmd.Flags().GetString("session-token") + if err := validateAgentVaultRevokeTarget(args, cmd.Flags().Changed("session-token"), token); err != nil { + util.HandleError(err) + } + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Failed to build the API client") + } + + byToken := len(args) == 0 + var sessionID string + if byToken { + sessionID, err = revokeAgentVaultSessionByToken(httpClient, strings.TrimSpace(token)) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + util.HandleError(err, "Infisical does not recognize this session token. Check that it was copied whole") + } + util.HandleError(err, "Unable to revoke the session") + } + } else { + sessionID = args[0] + identity := resolveAgentVaultIdentityToken(cmd) + httpClient.SetAuthToken(identity) + if err := api.CallRevokeAgentVaultSession(context.Background(), httpClient, sessionID); err != nil { + util.HandleError(err, "Unable to revoke the session") + } + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) + } + + Telemetry.CaptureEvent("cli-command:agent-vault sessions revoke", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("platform", runtime.GOOS). + Set("by", map[bool]string{true: "token", false: "id"}[byToken])) + + fmt.Printf("Session %s is revoked\n", sessionID) +} + +func validateAgentVaultRevokeTarget(args []string, tokenGiven bool, token string) error { + switch { + case len(args) == 1 && tokenGiven: + return errors.New("pass a session ID or --session-token, not both") + case len(args) == 1: + if _, err := uuid.Parse(args[0]); err != nil || len(args[0]) != 36 { + return fmt.Errorf("%q is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token", args[0]) + } + return nil + case !tokenGiven: + return errors.New("name the session to revoke: pass its ID, or --session-token with its session token") + case strings.TrimSpace(token) == "": + return errors.New("--session-token was given but is empty; pass the session token, or pass the session's ID instead") + default: + return nil + } +} + +func revokeAgentVaultSessionByToken(httpClient *resty.Client, token string) (string, error) { + res, err := api.CallRevokeAgentVaultSessionByToken(context.Background(), httpClient, token) + if err != nil { + return "", err + } + return res.Session.ID, nil +} + +// The variable names one bundle and the flag is repeatable only so that passing it twice is refused by name. +func validateAgentVaultAccessBundles(bundles []string, source agentVaultSource) error { + if len(bundles) > 1 { + return errors.New("a session carries one access bundle; pass --access-bundle once") + } + for _, name := range bundles { + if !agentVaultBundleNameRe.MatchString(name) { + return fmt.Errorf("%q from %s is not an access bundle name; names are lowercase letters, numbers and hyphens, for example 'coding-agent'", + name, agentVaultSourceName(source, "--access-bundle", agentVaultAccessBundleEnv)) + } + } + return nil +} + +func mintAgentVaultSession(httpClient *resty.Client, project agentVaultProject, accessBundles []string, ttl, label string) (api.AgentVaultSession, error) { + created, err := api.CallCreateAgentVaultSession(httpClient, api.CreateAgentVaultSessionRequest{ + ProjectID: project.ID, + AccessBundles: accessBundles, + TTL: ttl, + Label: label, + }) + if err != nil { + return api.AgentVaultSession{}, err + } + return created.Session, nil +} + +func mintAgentVaultSessionOrExit(identity string, project agentVaultProject, accessBundles []string, ttl, label string) *api.AgentVaultSession { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Failed to build the API client") + } + httpClient.SetAuthToken(identity) + + minted, err := mintAgentVaultSession(httpClient, project, accessBundles, ttl, label) + if err != nil { + util.HandleError(err, append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) + } + return &minted +} + +func init() { + agentVaultSessionsCreateCmd.Flags().String("project-id", "", "Agent Vault project to create the session in (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") + agentVaultSessionsCreateCmd.Flags().StringArray("access-bundle", nil, "create the session over the access bundle with this `name` (falls back to "+agentVaultAccessBundleEnv+")") + agentVaultSessionsCreateCmd.Flags().String("ttl", "never", "lifetime of the session: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") + agentVaultSessionsCreateCmd.Flags().String("label", "", "describe what the session is for (up to 128 characters)") + agentVaultSessionsCreateCmd.Flags().String("client-id", "", "universal auth client id of the machine identity that creates the session (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") + agentVaultSessionsCreateCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") + agentVaultSessionsCreateCmd.Flags().StringP("output", "o", "", "print the session's ID, token, project, access bundle, label and expiry as json or yaml, instead of only its token") + + agentVaultSessionsRevokeCmd.Flags().String("session-token", "", "revoke the session this session token belongs to, instead of naming it by ID") + agentVaultSessionsRevokeCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to revoke as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") + agentVaultSessionsRevokeCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") + + agentVaultSessionsCmd.AddCommand(agentVaultSessionsCreateCmd) + agentVaultSessionsCmd.AddCommand(agentVaultSessionsRevokeCmd) + agentVaultCmd.AddCommand(agentVaultSessionsCmd) +} diff --git a/packages/cmd/agent_vault_sessions_test.go b/packages/cmd/agent_vault_sessions_test.go new file mode 100644 index 000000000..b99c59340 --- /dev/null +++ b/packages/cmd/agent_vault_sessions_test.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func TestMintAgentVaultSessionSendsTheProject(t *testing.T) { + var sent map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/agent-vault/sessions" { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &sent) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"session":{"id":"s1","token":"agv_x","projectId":"` + projectFromFlag + `","label":null,"expiresAt":null,"createdAt":"2026-11-04T00:00:00Z","accessBundles":[{"id":"b1","name":"coding-agent"}]}}`)) + })) + t.Cleanup(srv.Close) + old := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + t.Cleanup(func() { config.INFISICAL_URL = old }) + + minted, err := mintAgentVaultSession(resty.New(), agentVaultProject{ID: projectFromFlag}, []string{"coding-agent"}, "8h", "") + if err != nil { + t.Fatal(err) + } + if sent["projectId"] != projectFromFlag || sent["ttl"] != "8h" { + t.Fatalf("body %v", sent) + } + if _, ok := sent["label"]; ok { + t.Fatalf("an empty label should stay out of the body: %v", sent) + } + if minted.Token != "agv_x" || minted.ProjectID != projectFromFlag || len(minted.AccessBundles) != 1 || minted.AccessBundles[0].Name != "coding-agent" { + t.Fatalf("minted %+v", minted) + } +} + +func TestAgentVaultSessionOutputFormats(t *testing.T) { + label, expires := "nightly deploy", "2026-11-05T00:00:00Z" + out := agentVaultSessionOutputFrom(api.AgentVaultSession{ + ID: "s1", Token: "agv_x", ProjectID: projectFromFlag, Label: &label, ExpiresAt: &expires, + AccessBundles: []api.AgentVaultSessionAccessBundle{{ID: "b1", Name: "coding-agent"}}, + }, agentVaultProject{ID: projectFromFlag}, "coding-agent") + want := map[string]any{ + "id": "s1", "token": "agv_x", "projectId": projectFromFlag, "accessBundle": "coding-agent", + "label": label, "expiresAt": expires, + } + + for _, format := range []string{util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + formatted, err := util.FormatOutput(format, out, nil) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if format == util.OUTPUT_FORMAT_JSON { + err = json.Unmarshal([]byte(formatted), &got) + } else { + err = yaml.Unmarshal([]byte(formatted), &got) + } + if err != nil { + t.Fatalf("%s: %v", format, err) + } + if len(got) != len(want) { + t.Fatalf("%s: keys %v, want %v", format, got, want) + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s: %s = %v, want %v", format, key, got[key], value) + } + } + } +} + +func TestAgentVaultSessionOutputWithNoExpiryOrLabelPrintsNull(t *testing.T) { + formatted, err := util.FormatOutput(util.OUTPUT_FORMAT_JSON, agentVaultSessionOutputFrom(api.AgentVaultSession{ID: "s1", Token: "agv_x"}, agentVaultProject{ID: projectFromFlag}, "coding-agent"), nil) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{`"expiresAt":null`, `"label":null`, `"projectId":"` + projectFromFlag + `"`, `"accessBundle":"coding-agent"`} { + if !strings.Contains(formatted, part) { + t.Errorf("want %s in %s", part, formatted) + } + } +} + +func TestValidateAgentVaultRevokeTarget(t *testing.T) { + id := "0b6f3a1e-1111-4111-8111-111111111111" + cases := []struct { + name string + args []string + tokenGiven bool + token string + wantErr string + }{ + {"by id", []string{id}, false, "", ""}, + {"by token", nil, true, "agv_x", ""}, + {"both", []string{id}, true, "agv_x", "not both"}, + {"neither", nil, false, "", "name the session"}, + {"empty token", nil, true, " ", "--session-token was given but is empty"}, + {"not an id", []string{"agv_x"}, false, "", "is not a session ID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAgentVaultRevokeTarget(tc.args, tc.tokenGiven, tc.token) + if tc.wantErr == "" { + if err != nil { + t.Fatal(err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err %v, want %q", err, tc.wantErr) + } + }) + } +} + +func TestRevokeAgentVaultSessionByTokenSendsOnlyTheToken(t *testing.T) { + var auth, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth, path = r.Header.Get("Authorization"), r.Method+" "+r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"session":{"id":"s1","revokedAt":"2026-11-04T00:00:00Z"}}`)) + })) + t.Cleanup(srv.Close) + old := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + t.Cleanup(func() { config.INFISICAL_URL = old }) + + id, err := revokeAgentVaultSessionByToken(resty.New(), "agv_x") + if err != nil { + t.Fatal(err) + } + if id != "s1" || auth != "Bearer agv_x" || path != "POST /api/v1/agent-vault/session/revoke" { + t.Fatalf("id %q, auth %q, path %q", id, auth, path) + } +} + +func TestRefuseAgentVaultMintOnlyFlagsRefusesAProjectWithASessionToken(t *testing.T) { + newCmd := func(args ...string) *cobra.Command { + cmd := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("ttl", "never", "") + cmd.Flags().Bool("keep-session", false, "") + cmd.Flags().String("project-id", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd + } + + err := refuseAgentVaultMintOnlyFlags(newCmd("--project-id", projectFromFlag), "agv_x", agentVaultSourceEnv) + if err == nil || !strings.Contains(err.Error(), "--project-id") || !strings.Contains(err.Error(), agentVaultSessionTokenEnv) { + t.Fatalf("err %v", err) + } + if err := refuseAgentVaultMintOnlyFlags(newCmd("--project-id", projectFromFlag), "", agentVaultSourceNone); err != nil { + t.Fatalf("minting takes --project-id: %v", err) + } + // A project in the environment is ambient, so a run on a dashboard session just ignores it. + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + if err := refuseAgentVaultMintOnlyFlags(newCmd(), "agv_x", agentVaultSourceFlag); err != nil { + t.Fatalf("err %v", err) + } +} diff --git a/packages/cmd/agent_vault_skills.go b/packages/cmd/agent_vault_skills.go index 984b70f22..8496e4888 100644 --- a/packages/cmd/agent_vault_skills.go +++ b/packages/cmd/agent_vault_skills.go @@ -28,7 +28,10 @@ var agentVaultSkillsPushCmd = &cobra.Command{ Long: `Create a skill from a folder, or publish the folder as its next version. The folder holds one skill, with SKILL.md at its root. The name in the SKILL.md header picks the skill: a new -name creates it, and an existing one gets a new version unless nothing changed.`, +name creates it, and an existing one gets a new version unless nothing changed. + +The skill belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, +agent-vault.project-id in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order.`, Example: " infisical agent-vault skills push ./skills/code-review", DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), @@ -37,6 +40,11 @@ name creates it, and an existing one gets a new version unless nothing changed.` if err != nil { util.HandleError(err) } + project, err := resolveAgentVaultProject(cmd) + if err != nil { + util.HandleError(err) + } + echoAgentVaultProject(project) identity := resolveAgentVaultIdentityToken(cmd) httpClient, err := util.GetRestyClientWithCustomHeaders() @@ -45,19 +53,19 @@ name creates it, and an existing one gets a new version unless nothing changed.` } httpClient.SetAuthToken(identity) - result, err := pushAgentVaultSkill(httpClient, name, api.AgentVaultSkillFilesRequest{Files: files}) + result, err := pushAgentVaultSkill(httpClient, project.ID, name, files) if err != nil { - util.HandleError(err) + util.HandleError(err, agentVaultProjectHints(err, project)...) } fmt.Println(result) }, } -// pushAgentVaultSkill publishes a version of the skill with this name, or creates it. A create that loses a +// pushAgentVaultSkill publishes a version of the skill with this name in the project, or creates it there. A create that loses a // race with another push of the same name gets 409, and publishes to the skill the other push made. -func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.AgentVaultSkillFilesRequest) (string, error) { +func pushAgentVaultSkill(httpClient *resty.Client, projectID, name string, files []api.AgentVaultSkillFile) (string, error) { publish := func(skillID string) (string, error) { - published, err := api.CallUpdateAgentVaultSkill(httpClient, skillID, request) + published, err := api.CallUpdateAgentVaultSkill(httpClient, skillID, api.AgentVaultSkillFilesRequest{Files: files}) if err != nil { return "", fmt.Errorf("unable to publish a new version of %q: %w", name, err) } @@ -67,7 +75,7 @@ func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.Agen return fmt.Sprintf("%s: v%d", name, published.Version.Version), nil } find := func() (string, error) { - listed, err := api.CallListAgentVaultSkillsByName(httpClient, name) + listed, err := api.CallListAgentVaultSkillsByName(httpClient, projectID, name) if err != nil { return "", fmt.Errorf("unable to look up the skill %q: %w", name, err) } @@ -87,7 +95,7 @@ func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.Agen return publish(skillID) } - created, err := api.CallCreateAgentVaultSkill(httpClient, request) + created, err := api.CallCreateAgentVaultSkill(httpClient, api.CreateAgentVaultSkillRequest{ProjectID: projectID, Files: files}) var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { if existing, findErr := find(); findErr == nil && existing != "" { @@ -173,6 +181,7 @@ func readAgentVaultSkillFolder(dir string) ([]api.AgentVaultSkillFile, string, e } func init() { + agentVaultSkillsPushCmd.Flags().String("project-id", "", "Agent Vault project the skill belongs to (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") agentVaultSkillsPushCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to push as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") agentVaultSkillsPushCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") diff --git a/packages/cmd/agent_vault_skills_test.go b/packages/cmd/agent_vault_skills_test.go index 2bdc5034a..06874eb81 100644 --- a/packages/cmd/agent_vault_skills_test.go +++ b/packages/cmd/agent_vault_skills_test.go @@ -119,15 +119,21 @@ func newFakeSkillsAPI(t *testing.T, f *fakeSkillsAPI) *resty.Client { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() - body, _ := io.ReadAll(r.Body) + raw, _ := io.ReadAll(r.Body) + var sent map[string]json.RawMessage + _ = json.Unmarshal(raw, &sent) + // What a version holds is its files, so that is what decides whether a publish changed anything. + body := string(sent["files"]) w.Header().Set("Content-Type", "application/json") path := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-vault") switch { case r.Method == http.MethodGet && path == "/skills": - f.calls = append(f.calls, "find "+r.URL.Query().Get("name")) + f.calls = append(f.calls, "find "+r.URL.Query().Get("projectId")+" "+r.URL.Query().Get("name")) _ = json.NewEncoder(w).Encode(map[string]any{"skills": f.existing, "totalCount": len(f.existing)}) case r.Method == http.MethodPost && path == "/skills": - f.calls = append(f.calls, "create") + var projectID string + _ = json.Unmarshal(sent["projectId"], &projectID) + f.calls = append(f.calls, "create "+projectID) if f.createStatus != 0 { f.existing = f.existingAfterConflict w.WriteHeader(f.createStatus) @@ -135,15 +141,19 @@ func newFakeSkillsAPI(t *testing.T, f *fakeSkillsAPI) *resty.Client { return } f.existing = []api.AgentVaultSkill{{ID: "new", Name: "code-review"}} - f.lastBody, f.lastVersion = string(body), 1 + f.lastBody, f.lastVersion = body, 1 _ = json.NewEncoder(w).Encode(map[string]any{"skill": map[string]any{"id": "new", "name": "code-review"}, "version": map[string]any{"version": 1}}) case r.Method == http.MethodPatch && strings.Count(path, "/") == 2: - f.calls = append(f.calls, "update "+strings.Split(path, "/")[2]) - if f.lastBody == string(body) { + call := "update " + strings.Split(path, "/")[2] + if _, ok := sent["projectId"]; ok { + call += " with a projectId" + } + f.calls = append(f.calls, call) + if f.lastBody == body { _ = json.NewEncoder(w).Encode(map[string]any{"version": map[string]any{"version": f.lastVersion}, "unchanged": true}) return } - f.lastBody, f.lastVersion = string(body), 4 + f.lastBody, f.lastVersion = body, 4 _ = json.NewEncoder(w).Encode(map[string]any{"version": map[string]any{"version": 4}, "unchanged": false}) default: http.NotFound(w, r) @@ -165,18 +175,18 @@ func TestPushAgentVaultSkill(t *testing.T) { wantOut string wantErr bool }{ - {"new name creates", &fakeSkillsAPI{}, "find code-review,create", "code-review: created v1", false}, - {"known name publishes", &fakeSkillsAPI{existing: found}, "find code-review,update sk1", "code-review: v4", false}, + {"new name creates", &fakeSkillsAPI{}, "find p1 code-review,create p1", "code-review: created v1", false}, + {"known name publishes", &fakeSkillsAPI{existing: found}, "find p1 code-review,update sk1", "code-review: v4", false}, {"lost create race publishes", &fakeSkillsAPI{createStatus: http.StatusConflict, existingAfterConflict: found}, - "find code-review,create,find code-review,update sk1", "code-review: v4", false}, + "find p1 code-review,create p1,find p1 code-review,update sk1", "code-review: v4", false}, {"409 with nothing to find fails", &fakeSkillsAPI{createStatus: http.StatusConflict}, - "find code-review,create,find code-review", "", true}, + "find p1 code-review,create p1,find p1 code-review", "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := tc.api client := newFakeSkillsAPI(t, f) - out, err := pushAgentVaultSkill(client, "code-review", api.AgentVaultSkillFilesRequest{}) + out, err := pushAgentVaultSkill(client, "p1", "code-review", nil) if (err != nil) != tc.wantErr || out != tc.wantOut { t.Fatalf("out %q, err %v", out, err) } @@ -201,7 +211,7 @@ func TestPushAgentVaultSkillTwiceIsUnchanged(t *testing.T) { if err != nil { t.Fatal(err) } - out, err := pushAgentVaultSkill(client, name, api.AgentVaultSkillFilesRequest{Files: files}) + out, err := pushAgentVaultSkill(client, "p1", name, files) if err != nil { t.Fatal(err) } @@ -210,7 +220,7 @@ func TestPushAgentVaultSkillTwiceIsUnchanged(t *testing.T) { if got := strings.Join(outs, "|"); got != "code-review: created v1|code-review: unchanged (v1)" { t.Fatalf("outputs %s", got) } - if got := strings.Join(f.calls, ","); got != "find code-review,create,find code-review,update new" { + if got := strings.Join(f.calls, ","); got != "find p1 code-review,create p1,find p1 code-review,update new" { t.Fatalf("calls %s", got) } } diff --git a/packages/cmd/profile.go b/packages/cmd/profile.go index 7f661d12d..28d0b0a79 100644 --- a/packages/cmd/profile.go +++ b/packages/cmd/profile.go @@ -236,6 +236,7 @@ var profileCurrentCmd = &cobra.Command{ util.PrintlnStdout("Sub-organization:", labelWithID(subName, profile.SubOrganizationID)) } util.PrintlnStdout("Domain:", util.DisplayDomain(profile.Domain)) + printAgentVaultProjectForProfile() Telemetry.CaptureEvent("cli-command:profile current", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, diff --git a/packages/models/cli.go b/packages/models/cli.go index d48b5e405..f12cd2442 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -180,6 +180,7 @@ type WorkspaceConfigFile struct { GitBranchToEnvironmentMapping map[string]string `json:"gitBranchToEnvironmentMapping"` DefaultSecretPath string `json:"defaultSecretPath,omitempty"` Domain string `json:"domain,omitempty"` + AgentVaultProjectID string `json:"-"` } type GeneralConfigSection struct { @@ -211,10 +212,15 @@ type SecretsManagementConfigSection struct { } `yaml:"mappings,omitempty"` } +type AgentVaultConfigSection struct { + ProjectID string `yaml:"project-id,omitempty"` +} + type WorkspaceConfigFileYaml struct { Version int `yaml:"version"` General GeneralConfigSection `yaml:"general"` SecretsManagement SecretsManagementConfigSection `yaml:"secrets-management"` + AgentVault AgentVaultConfigSection `yaml:"agent-vault,omitempty"` } type SymmetricEncryptionResult struct { diff --git a/packages/util/config.go b/packages/util/config.go index ffaa4814d..917434a67 100644 --- a/packages/util/config.go +++ b/packages/util/config.go @@ -120,6 +120,7 @@ func workspaceConfigFromYaml(workspaceConfigYaml models.WorkspaceConfigFileYaml) GitBranchToEnvironmentMapping: secretsManagement.Mappings.GitBranchToEnvironment, DefaultSecretPath: secretsManagement.DefaultSecretPath, Domain: workspaceConfigYaml.General.Domain, + AgentVaultProjectID: workspaceConfigYaml.AgentVault.ProjectID, } if secretsManagement.Environment != "" { diff --git a/packages/util/config_test.go b/packages/util/config_test.go index 28e5ab20d..159184c64 100644 --- a/packages/util/config_test.go +++ b/packages/util/config_test.go @@ -4,9 +4,11 @@ import ( "maps" "os" "path/filepath" + "strings" "testing" "github.com/Infisical/infisical-merge/packages/models" + "gopkg.in/yaml.v3" ) func TestWorkspaceConfigDomain(t *testing.T) { @@ -429,3 +431,26 @@ func TestGetWorkSpaceFromFileReadsParentLegacyConfig(t *testing.T) { t.Errorf("FindWorkspaceConfigFile = %q, want the legacy json file", found) } } + +func TestWorkspaceConfigYamlAgentVaultProject(t *testing.T) { + dir := t.TempDir() + content := "version: 1\nsecrets-management:\n project-id: sm\nagent-vault:\n project-id: av\n" + if err := os.WriteFile(filepath.Join(dir, INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := GetWorkSpaceFromFilePath(dir) + if err != nil { + t.Fatal(err) + } + if cfg.AgentVaultProjectID != "av" || cfg.WorkspaceId != "sm" { + t.Fatalf("got %+v", cfg) + } + + written, err := yaml.Marshal(models.WorkspaceConfigFileYaml{Version: 1, SecretsManagement: models.SecretsManagementConfigSection{ProjectID: "sm"}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(written), "agent-vault") { + t.Fatalf("a file written without an Agent Vault project should have no agent-vault section:\n%s", written) + } +} From ba9d110e724301fed6327d456c311d71a54775f1 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:39:30 +0530 Subject: [PATCH 02/11] fix(agent-vault): address review: revoke by env token, trusted domain and timeout; drop .infisical.yaml project - sessions revoke falls back to INFISICAL_AGENT_VAULT_SESSION_TOKEN - revoke by token ignores the domain in .infisical.yaml, like run --session-token - both revoke calls time out after 30s - drop the agent-vault project-id setting in .infisical.yaml --- packages/cmd/agent_vault_project.go | 24 +--- packages/cmd/agent_vault_project_test.go | 110 +++++++++--------- packages/cmd/agent_vault_run.go | 6 +- packages/cmd/agent_vault_run_skills.go | 2 +- packages/cmd/agent_vault_sessions.go | 86 ++++++++------ packages/cmd/agent_vault_sessions_test.go | 131 ++++++++++++++++++---- packages/cmd/agent_vault_skills.go | 6 +- packages/models/cli.go | 6 - packages/util/config.go | 1 - packages/util/config_test.go | 25 ----- 10 files changed, 234 insertions(+), 163 deletions(-) diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go index 28d685d6d..b2ccd4d66 100644 --- a/packages/cmd/agent_vault_project.go +++ b/packages/cmd/agent_vault_project.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "os" - "path/filepath" "strings" "github.com/Infisical/infisical-merge/packages/api" @@ -18,14 +17,14 @@ const agentVaultProjectIDEnv = "INFISICAL_AGENT_VAULT_PROJECT_ID" type agentVaultProject struct { ID string - // The flag, the variable or the file the ID came from, as messages name it. + // The flag or the variable the ID came from, as messages name it. Source string FromFlag bool } // resolveAgentVaultProject picks the Agent Vault project from --project-id, then INFISICAL_AGENT_VAULT_PROJECT_ID, -// then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID. The last is shared with Secrets -// Management, so it comes after everything that can only name an Agent Vault project. +// then INFISICAL_PROJECT_ID. The last is shared with Secrets Management, so it comes after the variable that can +// only name an Agent Vault project. func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { if cmd.Flags().Changed("project-id") { value, err := cmd.Flags().GetString("project-id") @@ -48,8 +47,8 @@ func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { } if !found { return agentVaultProject{}, fmt.Errorf( - "no Agent Vault project is set. Pass --project-id, set %s, add agent-vault.project-id to %s, or set %s", - agentVaultProjectIDEnv, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME, util.INFISICAL_PROJECT_ID_NAME) + "no Agent Vault project is set. Pass --project-id, or set %s or %s", + agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME) } return project, nil } @@ -61,17 +60,6 @@ func resolveAgentVaultProjectFromEnvironment() (project agentVaultProject, found return project, true, err } - if path, findErr := util.FindWorkspaceConfigFile(); findErr == nil { - workspace, readErr := util.GetWorkSpaceFromFilePath(filepath.Dir(path)) - if readErr != nil { - return agentVaultProject{}, false, fmt.Errorf("unable to read %s: %w", path, readErr) - } - if value := strings.TrimSpace(workspace.AgentVaultProjectID); value != "" { - project, err = agentVaultProjectFrom(value, path) - return project, true, err - } - } - if value := strings.TrimSpace(os.Getenv(util.INFISICAL_PROJECT_ID_NAME)); value != "" { project, err = agentVaultProjectFrom(value, util.INFISICAL_PROJECT_ID_NAME) return project, true, err @@ -117,7 +105,7 @@ func agentVaultProjectSourceKind(project agentVaultProject) string { case util.INFISICAL_PROJECT_ID_NAME: return "project-env" default: - return "config-file" + return "unknown" } } diff --git a/packages/cmd/agent_vault_project_test.go b/packages/cmd/agent_vault_project_test.go index 6159310d5..09a27748f 100644 --- a/packages/cmd/agent_vault_project_test.go +++ b/packages/cmd/agent_vault_project_test.go @@ -1,8 +1,8 @@ package cmd import ( - "os" - "path/filepath" + "bytes" + "io" "strings" "testing" @@ -14,7 +14,6 @@ import ( const ( projectFromFlag = "11111111-1111-4111-8111-111111111111" projectFromAgvEnv = "22222222-2222-4222-8222-222222222222" - projectFromFile = "33333333-3333-4333-8333-333333333333" projectFromProjEnv = "44444444-4444-4444-8444-444444444444" ) @@ -28,29 +27,14 @@ func projectIDCmd(t *testing.T, args ...string) *cobra.Command { return cmd } -// isolateAgentVaultProject clears every source and moves into an empty folder, so no real .infisical.yaml is read. -func isolateAgentVaultProject(t *testing.T) string { +func isolateAgentVaultProject(t *testing.T) { t.Helper() t.Setenv(agentVaultProjectIDEnv, "") t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "") - dir := t.TempDir() - t.Chdir(dir) - return dir -} - -func writeAgentVaultWorkspaceFile(t *testing.T, dir, projectID string) string { - t.Helper() - path := filepath.Join(dir, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) - content := "version: 1\nsecrets-management:\n project-id: 99999999-9999-4999-8999-999999999999\nagent-vault:\n project-id: " + projectID + "\n" - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - return path } func TestResolveAgentVaultProjectOrder(t *testing.T) { - dir := isolateAgentVaultProject(t) - path := writeAgentVaultWorkspaceFile(t, dir, projectFromFile) + isolateAgentVaultProject(t) t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) @@ -62,11 +46,6 @@ func TestResolveAgentVaultProjectOrder(t *testing.T) { }{ {[]string{"--project-id", projectFromFlag}, projectFromFlag, "--project-id", nil}, {nil, projectFromAgvEnv, agentVaultProjectIDEnv, func() { t.Setenv(agentVaultProjectIDEnv, "") }}, - {nil, projectFromFile, path, func() { - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - }}, {nil, projectFromProjEnv, util.INFISICAL_PROJECT_ID_NAME, nil}, } for _, step := range steps { @@ -74,7 +53,7 @@ func TestResolveAgentVaultProjectOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if project.ID != step.wantID || !sameAgentVaultProjectSource(project.Source, step.wantSource) { + if project.ID != step.wantID || project.Source != step.wantSource { t.Fatalf("got %s from %s, want %s from %s", project.ID, project.Source, step.wantID, step.wantSource) } if project.FromFlag != (step.wantSource == "--project-id") { @@ -86,16 +65,6 @@ func TestResolveAgentVaultProjectOrder(t *testing.T) { } } -// The temp folder is reached through a symlink on macOS, so a path source may name it either way. -func sameAgentVaultProjectSource(got, want string) bool { - if got == want { - return true - } - gotPath, gotErr := filepath.EvalSymlinks(got) - wantPath, wantErr := filepath.EvalSymlinks(want) - return gotErr == nil && wantErr == nil && gotPath == wantPath -} - func TestResolveAgentVaultProjectRefusesAnEmptyFlag(t *testing.T) { isolateAgentVaultProject(t) t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) @@ -107,7 +76,7 @@ func TestResolveAgentVaultProjectRefusesAnEmptyFlag(t *testing.T) { } func TestResolveAgentVaultProjectRefusesANonUuidNamingItsSource(t *testing.T) { - dir := isolateAgentVaultProject(t) + isolateAgentVaultProject(t) if _, err := resolveAgentVaultProject(projectIDCmd(t, "--project-id", "my-project")); err == nil || !strings.Contains(err.Error(), `"my-project" from --project-id`) { t.Fatalf("flag: %v", err) @@ -118,9 +87,9 @@ func TestResolveAgentVaultProjectRefusesANonUuidNamingItsSource(t *testing.T) { t.Fatalf("braced uuid from %s: %v", util.INFISICAL_PROJECT_ID_NAME, err) } - writeAgentVaultWorkspaceFile(t, dir, "nope") - if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) { - t.Fatalf("file: %v", err) + t.Setenv(agentVaultProjectIDEnv, "my-agent-vault") + if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), `"my-agent-vault" from `+agentVaultProjectIDEnv) { + t.Fatalf("%s: %v", agentVaultProjectIDEnv, err) } } @@ -131,23 +100,13 @@ func TestResolveAgentVaultProjectWithNothingSetNamesEverySource(t *testing.T) { if err == nil { t.Fatal("want an error when no project is set") } - for _, source := range []string{"--project-id", agentVaultProjectIDEnv, "agent-vault.project-id", util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME, util.INFISICAL_PROJECT_ID_NAME} { + for _, source := range []string{"--project-id", agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME} { if !strings.Contains(err.Error(), source) { t.Errorf("the message should name %s: %q", source, err) } } -} - -func TestResolveAgentVaultProjectSkipsAFileWithoutAnAgentVaultSection(t *testing.T) { - dir := isolateAgentVaultProject(t) - if err := os.WriteFile(filepath.Join(dir, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte("version: 1\nsecrets-management:\n project-id: abc\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) - - project, err := resolveAgentVaultProject(projectIDCmd(t)) - if err != nil || project.ID != projectFromProjEnv { - t.Fatalf("got %+v, %v", project, err) + if strings.Contains(err.Error(), util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) { + t.Errorf("the message should not offer a project file: %q", err) } } @@ -168,3 +127,48 @@ func TestAgentVaultProjectHintsOnlyFor404FromTheSharedVariable(t *testing.T) { t.Fatalf("a missing route is not a missing project: %v", hints) } } + +func captureAgentVaultStdout(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + old := util.GetStdoutWriter + util.GetStdoutWriter = func() io.Writer { return &buf } + t.Cleanup(func() { util.GetStdoutWriter = old }) + return &buf +} + +func TestPrintAgentVaultProjectForProfile(t *testing.T) { + isolateAgentVaultProject(t) + + out := captureAgentVaultStdout(t) + printAgentVaultProjectForProfile() + if out.Len() != 0 { + t.Fatalf("nothing set should print nothing, printed %q", out) + } + + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + printAgentVaultProjectForProfile() + if want := "Agent Vault project: " + projectFromAgvEnv + "\nAgent Vault project via: " + agentVaultProjectIDEnv + "\n"; out.String() != want { + t.Fatalf("printed %q, want %q", out, want) + } + + out.Reset() + t.Setenv(agentVaultProjectIDEnv, "my-agent-vault") + printAgentVaultProjectForProfile() + if !strings.HasPrefix(out.String(), "Agent Vault project: ") || !strings.Contains(out.String(), `"my-agent-vault" from `+agentVaultProjectIDEnv) { + t.Fatalf("printed %q", out) + } +} + +func TestAgentVaultProjectSourceKind(t *testing.T) { + for source, want := range map[string]string{ + "--project-id": "flag", + agentVaultProjectIDEnv: "agent-vault-env", + util.INFISICAL_PROJECT_ID_NAME: "project-env", + } { + if got := agentVaultProjectSourceKind(agentVaultProject{Source: source}); got != want { + t.Errorf("%s: got %s, want %s", source, got, want) + } + } +} diff --git a/packages/cmd/agent_vault_run.go b/packages/cmd/agent_vault_run.go index 553f2eb3c..e358579be 100644 --- a/packages/cmd/agent_vault_run.go +++ b/packages/cmd/agent_vault_run.go @@ -114,8 +114,8 @@ Two ways to get a session, exactly one of them required: --access-bundle mint one now over the named bundle, or set INFISICAL_AGENT_VAULT_ACCESS_BUNDLE (needs your login or a machine identity). Revoked when the agent exits unless --keep-session is set. The bundle is looked up in the Agent Vault project from - --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, agent-vault.project-id - in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order. + --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or INFISICAL_PROJECT_ID, + in that order. --session-token run with a session token minted in the dashboard, or set INFISICAL_AGENT_VAULT_SESSION_TOKEN. Never revoked by this command. @@ -856,7 +856,7 @@ func init() { agentVaultRunCmd.Flags().String("session-token", "", "run with a session token minted in the dashboard instead of minting one (falls back to "+agentVaultSessionTokenEnv+")") agentVaultRunCmd.Flags().String("ttl", "never", "lifetime of the session this command creates: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") agentVaultRunCmd.Flags().Bool("keep-session", false, "leave a minted session active when the agent exits") - agentVaultRunCmd.Flags().String("project-id", "", "Agent Vault project of the access bundle to mint over (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") + agentVaultRunCmd.Flags().String("project-id", "", "Agent Vault project of the access bundle to mint over (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") agentVaultRunCmd.Flags().String("label", "", "describe what the session this command creates is for (up to 128 characters)") agentVaultRunCmd.Flags().String("proxy", "", "address of the Agent Vault proxy as host:port (falls back to "+agentVaultProxyAddressEnv+")") agentVaultRunCmd.Flags().String("ca-fingerprint", "", "abort unless the proxy's certificate authority matches this SHA256 fingerprint from Infisical") diff --git a/packages/cmd/agent_vault_run_skills.go b/packages/cmd/agent_vault_run_skills.go index a6dfe367f..b63163669 100644 --- a/packages/cmd/agent_vault_run_skills.go +++ b/packages/cmd/agent_vault_run_skills.go @@ -60,7 +60,7 @@ func agentVaultSessionTokenAPIURL() string { } apiURL := util.AppendAPIEndpoint(domain) if fileDomain, valid := util.GetDomainFromFile(); valid && util.AppendAPIEndpoint(fileDomain) != apiURL { - util.PrintWarning(fmt.Sprintf("Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and getting skills from %s. Pass --domain to choose another.", strings.TrimSuffix(apiURL, "/api"))) + util.PrintWarning(fmt.Sprintf("Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and using %s. Pass --domain to choose another.", strings.TrimSuffix(apiURL, "/api"))) } return apiURL } diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index af234333f..be7f777ac 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -30,8 +30,8 @@ var agentVaultSessionsCreateCmd = &cobra.Command{ Short: "Create a session over an access bundle and print its token", Long: `Create a session over an access bundle and print its token. -The session belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, -agent-vault.project-id in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order. +The session belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or +INFISICAL_PROJECT_ID, in that order. Only the token is printed, so it can be captured into a variable. Pass -o json or -o yaml for the session's ID, project, access bundle, label and expiry as well. The session stays active until it expires or you revoke @@ -128,18 +128,20 @@ var agentVaultSessionsRevokeCmd = &cobra.Command{ Long: `Revoke a session by its ID or its token. By ID, the session is revoked as you, or as the machine identity from --client-id and --client-secret, which -needs access to it. By --session-token, the session token alone is enough: whoever holds it can end the session. -Revoking a session that has already ended succeeds.`, - Example: ` infisical agent-vault sessions revoke 0b6f3a1e-... - infisical agent-vault sessions revoke --session-token "$INFISICAL_AGENT_VAULT_SESSION_TOKEN"`, +needs access to it. Without an ID, the session token from --session-token, or else +INFISICAL_AGENT_VAULT_SESSION_TOKEN, names the session, and the token alone is enough: whoever holds it can end +the session. Revoking a session that has already ended succeeds.`, + Example: ` infisical agent-vault sessions revoke + infisical agent-vault sessions revoke 0b6f3a1e-... + infisical agent-vault sessions revoke --session-token "$TOKEN"`, DisableFlagsInUseLine: true, Args: cobra.MaximumNArgs(1), Run: runAgentVaultSessionsRevoke, } func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { - token, _ := cmd.Flags().GetString("session-token") - if err := validateAgentVaultRevokeTarget(args, cmd.Flags().Changed("session-token"), token); err != nil { + token, err := validateAgentVaultRevokeTarget(cmd, args) + if err != nil { util.HandleError(err) } @@ -148,23 +150,22 @@ func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to build the API client") } - byToken := len(args) == 0 + ctx, cancel := context.WithTimeout(cmd.Context(), agentVaultExitRevokeTimeout) + defer cancel() + + byToken := token != "" var sessionID string if byToken { - sessionID, err = revokeAgentVaultSessionByToken(httpClient, strings.TrimSpace(token)) + sessionID, err = revokeAgentVaultSessionByToken(ctx, httpClient, token) if err != nil { - var apiErr *api.APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { - util.HandleError(err, "Infisical does not recognize this session token. Check that it was copied whole") - } - util.HandleError(err, "Unable to revoke the session") + util.HandleError(err, agentVaultRevokeErrorMessages(ctx, err, true)...) } } else { sessionID = args[0] identity := resolveAgentVaultIdentityToken(cmd) httpClient.SetAuthToken(identity) - if err := api.CallRevokeAgentVaultSession(context.Background(), httpClient, sessionID); err != nil { - util.HandleError(err, "Unable to revoke the session") + if err := api.CallRevokeAgentVaultSession(ctx, httpClient, sessionID); err != nil { + util.HandleError(err, agentVaultRevokeErrorMessages(ctx, err, false)...) } Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) } @@ -177,32 +178,49 @@ func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { fmt.Printf("Session %s is revoked\n", sessionID) } -func validateAgentVaultRevokeTarget(args []string, tokenGiven bool, token string) error { - switch { - case len(args) == 1 && tokenGiven: - return errors.New("pass a session ID or --session-token, not both") - case len(args) == 1: +// validateAgentVaultRevokeTarget returns the session token to revoke by, or "" to revoke the session ID in args. +// A token in the environment is ignored when an ID is given, since the ID names the session outright. +func validateAgentVaultRevokeTarget(cmd *cobra.Command, args []string) (string, error) { + if len(args) == 1 { + if cmd.Flags().Changed("session-token") { + return "", errors.New("pass a session ID or --session-token, not both") + } if _, err := uuid.Parse(args[0]); err != nil || len(args[0]) != 36 { - return fmt.Errorf("%q is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token", args[0]) + return "", fmt.Errorf("%q is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token", args[0]) } - return nil - case !tokenGiven: - return errors.New("name the session to revoke: pass its ID, or --session-token with its session token") - case strings.TrimSpace(token) == "": - return errors.New("--session-token was given but is empty; pass the session token, or pass the session's ID instead") - default: - return nil + return "", nil + } + + token, _, err := resolveAgentVaultSessionToken(cmd) + if err != nil { + return "", err + } + if token == "" { + return "", fmt.Errorf("name the session to revoke: pass its ID, or --session-token, or set %s", agentVaultSessionTokenEnv) } + return token, nil } -func revokeAgentVaultSessionByToken(httpClient *resty.Client, token string) (string, error) { - res, err := api.CallRevokeAgentVaultSessionByToken(context.Background(), httpClient, token) +func revokeAgentVaultSessionByToken(ctx context.Context, httpClient *resty.Client, token string) (string, error) { + useAgentVaultSessionTokenDomain() + res, err := api.CallRevokeAgentVaultSessionByToken(ctx, httpClient, token) if err != nil { return "", err } return res.Session.ID, nil } +func agentVaultRevokeErrorMessages(ctx context.Context, err error, byToken bool) []string { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return []string{fmt.Sprintf("Infisical did not answer within %s, so the session may still be active. Try again, or revoke it in Infisical", agentVaultExitRevokeTimeout)} + } + var apiErr *api.APIError + if byToken && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + return []string{"Infisical does not recognize this session token. Check that it was copied whole"} + } + return []string{"Unable to revoke the session"} +} + // The variable names one bundle and the flag is repeatable only so that passing it twice is refused by name. func validateAgentVaultAccessBundles(bundles []string, source agentVaultSource) error { if len(bundles) > 1 { @@ -245,7 +263,7 @@ func mintAgentVaultSessionOrExit(identity string, project agentVaultProject, acc } func init() { - agentVaultSessionsCreateCmd.Flags().String("project-id", "", "Agent Vault project to create the session in (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") + agentVaultSessionsCreateCmd.Flags().String("project-id", "", "Agent Vault project to create the session in (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") agentVaultSessionsCreateCmd.Flags().StringArray("access-bundle", nil, "create the session over the access bundle with this `name` (falls back to "+agentVaultAccessBundleEnv+")") agentVaultSessionsCreateCmd.Flags().String("ttl", "never", "lifetime of the session: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") agentVaultSessionsCreateCmd.Flags().String("label", "", "describe what the session is for (up to 128 characters)") @@ -253,7 +271,7 @@ func init() { agentVaultSessionsCreateCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") agentVaultSessionsCreateCmd.Flags().StringP("output", "o", "", "print the session's ID, token, project, access bundle, label and expiry as json or yaml, instead of only its token") - agentVaultSessionsRevokeCmd.Flags().String("session-token", "", "revoke the session this session token belongs to, instead of naming it by ID") + agentVaultSessionsRevokeCmd.Flags().String("session-token", "", "revoke the session this session token belongs to, instead of naming it by ID (falls back to "+agentVaultSessionTokenEnv+" when no ID is given)") agentVaultSessionsRevokeCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to revoke as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") agentVaultSessionsRevokeCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") diff --git a/packages/cmd/agent_vault_sessions_test.go b/packages/cmd/agent_vault_sessions_test.go index b99c59340..691a343e6 100644 --- a/packages/cmd/agent_vault_sessions_test.go +++ b/packages/cmd/agent_vault_sessions_test.go @@ -1,12 +1,17 @@ package cmd import ( + "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" @@ -99,26 +104,39 @@ func TestAgentVaultSessionOutputWithNoExpiryOrLabelPrintsNull(t *testing.T) { func TestValidateAgentVaultRevokeTarget(t *testing.T) { id := "0b6f3a1e-1111-4111-8111-111111111111" cases := []struct { - name string - args []string - tokenGiven bool - token string - wantErr string + name string + args []string + envToken string + wantToken string + wantErr string }{ - {"by id", []string{id}, false, "", ""}, - {"by token", nil, true, "agv_x", ""}, - {"both", []string{id}, true, "agv_x", "not both"}, - {"neither", nil, false, "", "name the session"}, - {"empty token", nil, true, " ", "--session-token was given but is empty"}, - {"not an id", []string{"agv_x"}, false, "", "is not a session ID"}, + {"by id", []string{id}, "", "", ""}, + {"by id ignores the variable", []string{id}, "agv_env", "", ""}, + {"by token", []string{"--session-token", " agv_x "}, "", "agv_x", ""}, + {"the flag beats the variable", []string{"--session-token", "agv_x"}, "agv_env", "agv_x", ""}, + {"by the variable", nil, "agv_env", "agv_env", ""}, + {"both", []string{id, "--session-token", "agv_x"}, "", "", "not both"}, + {"neither", nil, "", "", "name the session to revoke"}, + {"empty token", []string{"--session-token", " "}, "agv_env", "", "--session-token was given but is empty"}, + {"not an id", []string{"agv_x"}, "", "", "is not a session ID"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := validateAgentVaultRevokeTarget(tc.args, tc.tokenGiven, tc.token) + t.Setenv(agentVaultSessionTokenEnv, tc.envToken) + cmd := &cobra.Command{Use: "revoke", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("session-token", "", "") + if err := cmd.Flags().Parse(tc.args); err != nil { + t.Fatal(err) + } + + token, err := validateAgentVaultRevokeTarget(cmd, cmd.Flags().Args()) if tc.wantErr == "" { if err != nil { t.Fatal(err) } + if token != tc.wantToken { + t.Fatalf("token %q, want %q", token, tc.wantToken) + } return } if err == nil || !strings.Contains(err.Error(), tc.wantErr) { @@ -128,19 +146,31 @@ func TestValidateAgentVaultRevokeTarget(t *testing.T) { } } -func TestRevokeAgentVaultSessionByTokenSendsOnlyTheToken(t *testing.T) { - var auth, path string +func fakeAgentVaultRevokeServer(t *testing.T, auth, path *string) *httptest.Server { + t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth, path = r.Header.Get("Authorization"), r.Method+" "+r.URL.Path + *auth, *path = r.Header.Get("Authorization"), r.Method+" "+r.URL.Path w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"session":{"id":"s1","revokedAt":"2026-11-04T00:00:00Z"}}`)) })) t.Cleanup(srv.Close) - old := config.INFISICAL_URL - config.INFISICAL_URL = srv.URL + "/api" - t.Cleanup(func() { config.INFISICAL_URL = old }) + return srv +} + +func useAgentVaultDomainExplicitlySet(t *testing.T, set bool) { + t.Helper() + old := config.INFISICAL_DOMAIN_EXPLICITLY_SET + config.INFISICAL_DOMAIN_EXPLICITLY_SET = set + t.Cleanup(func() { config.INFISICAL_DOMAIN_EXPLICITLY_SET = old }) +} - id, err := revokeAgentVaultSessionByToken(resty.New(), "agv_x") +func TestRevokeAgentVaultSessionByTokenSendsOnlyTheToken(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + useInfisicalURL(t, srv.URL+"/api") + useAgentVaultDomainExplicitlySet(t, true) + + id, err := revokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x") if err != nil { t.Fatal(err) } @@ -149,6 +179,69 @@ func TestRevokeAgentVaultSessionByTokenSendsOnlyTheToken(t *testing.T) { } } +func TestRevokeAgentVaultSessionByTokenIgnoresTheDomainInARepoFile(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + home := isolateSkillsHome(t) + if err := os.MkdirAll(filepath.Join(home, util.CONFIG_FOLDER_NAME), 0o700); err != nil { + t.Fatal(err) + } + profile := `{"LoggedInUserDomain":"` + srv.URL + `"}` + if err := os.WriteFile(filepath.Join(home, util.CONFIG_FOLDER_NAME, util.CONFIG_FILE_NAME), []byte(profile), 0o600); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte("version: 1\ngeneral:\n domain: https://evil.example\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + useInfisicalURL(t, "https://evil.example/api") + useAgentVaultDomainExplicitlySet(t, false) + warnings := captureWarnings(t) + + if _, err := revokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x"); err != nil { + t.Fatal(err) + } + if auth != "Bearer agv_x" || config.INFISICAL_URL != srv.URL+"/api" { + t.Fatalf("the token went to %s (auth %q)", config.INFISICAL_URL, auth) + } + if want := "Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and using " + srv.URL + "."; !strings.Contains(warnings.String(), want) { + t.Fatalf("warned %q", warnings.String()) + } +} + +func TestRevokeAgentVaultSessionStopsAtItsDeadline(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + useInfisicalURL(t, srv.URL+"/api") + useAgentVaultDomainExplicitlySet(t, true) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + if _, err := revokeAgentVaultSessionByToken(ctx, resty.New(), "agv_x"); err == nil { + t.Fatal("a revoke past its deadline should fail") + } + if err := api.CallRevokeAgentVaultSession(ctx, resty.New(), "s1"); err == nil { + t.Fatal("a revoke by ID past its deadline should fail") + } + if auth != "" { + t.Fatal("nothing should be sent past the deadline") + } + if messages := agentVaultRevokeErrorMessages(ctx, errors.New("timeout"), true); len(messages) != 1 || !strings.Contains(messages[0], "did not answer within 30s") { + t.Fatalf("messages %v", messages) + } +} + +func TestAgentVaultRevokeErrorMessages(t *testing.T) { + unauthorized := &api.APIError{StatusCode: http.StatusUnauthorized} + if messages := agentVaultRevokeErrorMessages(context.Background(), unauthorized, true); !strings.Contains(messages[0], "does not recognize this session token") { + t.Fatalf("by token: %v", messages) + } + if messages := agentVaultRevokeErrorMessages(context.Background(), unauthorized, false); messages[0] != "Unable to revoke the session" { + t.Fatalf("by ID: %v", messages) + } +} + func TestRefuseAgentVaultMintOnlyFlagsRefusesAProjectWithASessionToken(t *testing.T) { newCmd := func(args ...string) *cobra.Command { cmd := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} diff --git a/packages/cmd/agent_vault_skills.go b/packages/cmd/agent_vault_skills.go index 8496e4888..14bc76305 100644 --- a/packages/cmd/agent_vault_skills.go +++ b/packages/cmd/agent_vault_skills.go @@ -30,8 +30,8 @@ var agentVaultSkillsPushCmd = &cobra.Command{ The folder holds one skill, with SKILL.md at its root. The name in the SKILL.md header picks the skill: a new name creates it, and an existing one gets a new version unless nothing changed. -The skill belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID, -agent-vault.project-id in .infisical.yaml, or INFISICAL_PROJECT_ID, in that order.`, +The skill belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or +INFISICAL_PROJECT_ID, in that order.`, Example: " infisical agent-vault skills push ./skills/code-review", DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), @@ -181,7 +181,7 @@ func readAgentVaultSkillFolder(dir string) ([]api.AgentVaultSkillFile, string, e } func init() { - agentVaultSkillsPushCmd.Flags().String("project-id", "", "Agent Vault project the skill belongs to (falls back to "+agentVaultProjectIDEnv+", then agent-vault.project-id in .infisical.yaml, then INFISICAL_PROJECT_ID)") + agentVaultSkillsPushCmd.Flags().String("project-id", "", "Agent Vault project the skill belongs to (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") agentVaultSkillsPushCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to push as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") agentVaultSkillsPushCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") diff --git a/packages/models/cli.go b/packages/models/cli.go index f12cd2442..d48b5e405 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -180,7 +180,6 @@ type WorkspaceConfigFile struct { GitBranchToEnvironmentMapping map[string]string `json:"gitBranchToEnvironmentMapping"` DefaultSecretPath string `json:"defaultSecretPath,omitempty"` Domain string `json:"domain,omitempty"` - AgentVaultProjectID string `json:"-"` } type GeneralConfigSection struct { @@ -212,15 +211,10 @@ type SecretsManagementConfigSection struct { } `yaml:"mappings,omitempty"` } -type AgentVaultConfigSection struct { - ProjectID string `yaml:"project-id,omitempty"` -} - type WorkspaceConfigFileYaml struct { Version int `yaml:"version"` General GeneralConfigSection `yaml:"general"` SecretsManagement SecretsManagementConfigSection `yaml:"secrets-management"` - AgentVault AgentVaultConfigSection `yaml:"agent-vault,omitempty"` } type SymmetricEncryptionResult struct { diff --git a/packages/util/config.go b/packages/util/config.go index 917434a67..ffaa4814d 100644 --- a/packages/util/config.go +++ b/packages/util/config.go @@ -120,7 +120,6 @@ func workspaceConfigFromYaml(workspaceConfigYaml models.WorkspaceConfigFileYaml) GitBranchToEnvironmentMapping: secretsManagement.Mappings.GitBranchToEnvironment, DefaultSecretPath: secretsManagement.DefaultSecretPath, Domain: workspaceConfigYaml.General.Domain, - AgentVaultProjectID: workspaceConfigYaml.AgentVault.ProjectID, } if secretsManagement.Environment != "" { diff --git a/packages/util/config_test.go b/packages/util/config_test.go index 159184c64..28e5ab20d 100644 --- a/packages/util/config_test.go +++ b/packages/util/config_test.go @@ -4,11 +4,9 @@ import ( "maps" "os" "path/filepath" - "strings" "testing" "github.com/Infisical/infisical-merge/packages/models" - "gopkg.in/yaml.v3" ) func TestWorkspaceConfigDomain(t *testing.T) { @@ -431,26 +429,3 @@ func TestGetWorkSpaceFromFileReadsParentLegacyConfig(t *testing.T) { t.Errorf("FindWorkspaceConfigFile = %q, want the legacy json file", found) } } - -func TestWorkspaceConfigYamlAgentVaultProject(t *testing.T) { - dir := t.TempDir() - content := "version: 1\nsecrets-management:\n project-id: sm\nagent-vault:\n project-id: av\n" - if err := os.WriteFile(filepath.Join(dir, INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte(content), 0o600); err != nil { - t.Fatal(err) - } - cfg, err := GetWorkSpaceFromFilePath(dir) - if err != nil { - t.Fatal(err) - } - if cfg.AgentVaultProjectID != "av" || cfg.WorkspaceId != "sm" { - t.Fatalf("got %+v", cfg) - } - - written, err := yaml.Marshal(models.WorkspaceConfigFileYaml{Version: 1, SecretsManagement: models.SecretsManagementConfigSection{ProjectID: "sm"}}) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(written), "agent-vault") { - t.Fatalf("a file written without an Agent Vault project should have no agent-vault section:\n%s", written) - } -} From a4ab1d7c544185b33cde19f09978b4dd1dc28668 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:15:29 +0530 Subject: [PATCH 03/11] fix(agent-vault): start the revoke deadline after sign-in --- packages/cmd/agent_vault_sessions.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index e0be9cb93..ba1d5901c 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -150,10 +150,17 @@ func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to build the API client") } + byToken := token != "" + var identity string + if !byToken { + // Resolved before the deadline starts: it can open the login flow, which takes as long as the person does. + identity = resolveAgentVaultIdentityToken(cmd) + httpClient.SetAuthToken(identity) + } + ctx, cancel := context.WithTimeout(cmd.Context(), agentVaultExitRevokeTimeout) defer cancel() - byToken := token != "" var sessionID string if byToken { sessionID, err = revokeAgentVaultSessionByToken(ctx, httpClient, token) @@ -162,8 +169,6 @@ func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { } } else { sessionID = args[0] - identity := resolveAgentVaultIdentityToken(cmd) - httpClient.SetAuthToken(identity) if err := api.CallRevokeAgentVaultSession(ctx, httpClient, sessionID); err != nil { util.HandleError(err, agentVaultRevokeErrorMessages(ctx, err, false)...) } From a3cb22126856191876da7bd5383ec7531d7ef738 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:35:23 +0530 Subject: [PATCH 04/11] fix(agent-vault): address Scott's review - Agent Vault commands ignore the domain in .infisical.yaml - show the real login error instead of 'expired' - never echo a session token passed as the revoke argument - lowercase project IDs --- packages/cmd/agent_vault_project.go | 2 +- packages/cmd/agent_vault_project_test.go | 28 +++++++++++ packages/cmd/agent_vault_run.go | 14 +++++- packages/cmd/agent_vault_run_skills.go | 9 ++-- packages/cmd/agent_vault_run_test.go | 20 ++++++++ packages/cmd/agent_vault_sessions.go | 6 ++- packages/cmd/agent_vault_sessions_test.go | 12 +++-- packages/cmd/root.go | 15 ++++++ packages/cmd/root_domain_test.go | 57 +++++++++++++++++++++++ 9 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 packages/cmd/root_domain_test.go diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go index b2ccd4d66..9eaee1094 100644 --- a/packages/cmd/agent_vault_project.go +++ b/packages/cmd/agent_vault_project.go @@ -73,7 +73,7 @@ func agentVaultProjectFrom(value, source string) (agentVaultProject, error) { if _, err := uuid.Parse(value); err != nil || len(value) != 36 { return agentVaultProject{}, fmt.Errorf("%q from %s is not a project ID; copy the ID from the Agent Vault project's settings in Infisical", value, source) } - return agentVaultProject{ID: value, Source: source}, nil + return agentVaultProject{ID: strings.ToLower(value), Source: source}, nil } // A flag was typed for this run; anything else is ambient, so the command says which project it picked up. diff --git a/packages/cmd/agent_vault_project_test.go b/packages/cmd/agent_vault_project_test.go index 09a27748f..4e99345af 100644 --- a/packages/cmd/agent_vault_project_test.go +++ b/packages/cmd/agent_vault_project_test.go @@ -65,6 +65,34 @@ func TestResolveAgentVaultProjectOrder(t *testing.T) { } } +func TestResolveAgentVaultProjectLowercasesTheID(t *testing.T) { + isolateAgentVaultProject(t) + + const upper = "ABCDEF12-AAAA-4AAA-8AAA-AAAAAAAAAAAA" + want := strings.ToLower(upper) + sources := []struct { + name string + set func() *cobra.Command + }{ + {"--project-id", func() *cobra.Command { return projectIDCmd(t, "--project-id", upper) }}, + {agentVaultProjectIDEnv, func() *cobra.Command { t.Setenv(agentVaultProjectIDEnv, upper); return projectIDCmd(t) }}, + {util.INFISICAL_PROJECT_ID_NAME, func() *cobra.Command { + t.Setenv(agentVaultProjectIDEnv, "") + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, upper) + return projectIDCmd(t) + }}, + } + for _, source := range sources { + project, err := resolveAgentVaultProject(source.set()) + if err != nil { + t.Fatalf("%s: %v", source.name, err) + } + if project.Source != source.name || project.ID != want { + t.Fatalf("%s: got %s from %s, want %s", source.name, project.ID, project.Source, want) + } + } +} + func TestResolveAgentVaultProjectRefusesAnEmptyFlag(t *testing.T) { isolateAgentVaultProject(t) t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) diff --git a/packages/cmd/agent_vault_run.go b/packages/cmd/agent_vault_run.go index e358579be..67a1f7009 100644 --- a/packages/cmd/agent_vault_run.go +++ b/packages/cmd/agent_vault_run.go @@ -662,7 +662,10 @@ func resolveAgentVaultIdentity(cmd *cobra.Command, allowLoginWizard bool) string } details, err := util.GetCurrentLoggedInUserDetails(true) - if err != nil || !details.IsUserLoggedIn || details.LoginExpired { + if err = agentVaultLoginReadError(err); err != nil { + util.HandleError(err, "Unable to read your Infisical login") + } + if !details.IsUserLoggedIn || details.LoginExpired { if !allowLoginWizard { util.HandleError(fmt.Errorf("your Infisical login has expired or is missing, and this command's output is being captured, so it can't sign you in. Run 'infisical login' first, set %s, or pass --client-id and --client-secret", util.INFISICAL_TOKEN_NAME)) } @@ -675,6 +678,15 @@ func resolveAgentVaultIdentity(cmd *cobra.Command, allowLoginWizard bool) string return details.UserCredentials.JTWToken } +// A login missing from the keyring is a missing login, which signing in fixes. Any other error (a broken config +// file, an unreadable keyring, a profile for another domain) would not be fixed by the wizard, so it is shown. +func agentVaultLoginReadError(err error) error { + if err == nil || errors.Is(err, util.ErrUserNotLoggedIn) { + return nil + } + return err +} + // Windows resolves variable names case-insensitively, so os.Getenv finds a lowercase spelling and the run // uses it, while an exact match here would hand that same spelling to the agent. func isAgentVaultSessionTokenKey(key string) bool { diff --git a/packages/cmd/agent_vault_run_skills.go b/packages/cmd/agent_vault_run_skills.go index b63163669..53afb0c66 100644 --- a/packages/cmd/agent_vault_run_skills.go +++ b/packages/cmd/agent_vault_run_skills.go @@ -46,7 +46,8 @@ func newAgentVaultSkillsHTTPClient() (*resty.Client, error) { } // A session token goes only to a domain the user chose: --domain, the domain variables, or the logged-in -// profile. A .infisical.json can arrive with a cloned repo, so the domain it names never receives the token. +// profile, else US Cloud. A command holding only the token has not loaded the profile, so nothing else would +// point it at the profile's domain. func useAgentVaultSessionTokenDomain() { if !config.INFISICAL_DOMAIN_EXPLICITLY_SET { config.INFISICAL_URL = agentVaultSessionTokenAPIURL() @@ -58,11 +59,7 @@ func agentVaultSessionTokenAPIURL() string { if domain == "" { domain = util.INFISICAL_DEFAULT_US_URL } - apiURL := util.AppendAPIEndpoint(domain) - if fileDomain, valid := util.GetDomainFromFile(); valid && util.AppendAPIEndpoint(fileDomain) != apiURL { - util.PrintWarning(fmt.Sprintf("Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and using %s. Pass --domain to choose another.", strings.TrimSuffix(apiURL, "/api"))) - } - return apiURL + return util.AppendAPIEndpoint(domain) } // The waits before each retry of a list that answered 401 on a minted run. diff --git a/packages/cmd/agent_vault_run_test.go b/packages/cmd/agent_vault_run_test.go index da9717584..cc8ae1712 100644 --- a/packages/cmd/agent_vault_run_test.go +++ b/packages/cmd/agent_vault_run_test.go @@ -8,6 +8,8 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" + "fmt" "math/big" "net/http" "net/http/httptest" @@ -521,3 +523,21 @@ func TestResolveAgentVaultSessionLabelRefusesASessionToken(t *testing.T) { } } } + +func TestAgentVaultLoginReadErrorShowsAllButAMissingLogin(t *testing.T) { + if err := agentVaultLoginReadError(nil); err != nil { + t.Fatalf("no error: %v", err) + } + if err := agentVaultLoginReadError(util.ErrUserNotLoggedIn); err != nil { + t.Fatalf("a missing login should go to the sign-in path, not be shown as an error: %v", err) + } + for _, want := range []error{ + fmt.Errorf("%w: profile 'work' belongs to eu.infisical.com", util.ErrProfileDomainMismatch), + fmt.Errorf("%w: profile 'work' does not exist", util.ErrProfileNotFound), + errors.New("failed to fetch credentials from keyring because [err=locked]"), + } { + if got := agentVaultLoginReadError(want); got != want { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index ba1d5901c..568c2b48b 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -190,8 +190,12 @@ func validateAgentVaultRevokeTarget(cmd *cobra.Command, args []string) (string, if cmd.Flags().Changed("session-token") { return "", errors.New("pass a session ID or --session-token, not both") } + // The argument is never echoed: a token passed here by mistake would otherwise land in the terminal or a CI log. + if strings.HasPrefix(strings.TrimSpace(args[0]), "agv_") { + return "", fmt.Errorf("that looks like a session token, not a session ID. Pass it with --session-token, or set %s", agentVaultSessionTokenEnv) + } if _, err := uuid.Parse(args[0]); err != nil || len(args[0]) != 36 { - return "", fmt.Errorf("%q is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token", args[0]) + return "", errors.New("the argument is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token") } return "", nil } diff --git a/packages/cmd/agent_vault_sessions_test.go b/packages/cmd/agent_vault_sessions_test.go index 5bb1e8942..686f4c7d2 100644 --- a/packages/cmd/agent_vault_sessions_test.go +++ b/packages/cmd/agent_vault_sessions_test.go @@ -118,7 +118,8 @@ func TestValidateAgentVaultRevokeTarget(t *testing.T) { {"both", []string{id, "--session-token", "agv_x"}, "", "", "not both"}, {"neither", nil, "", "", "name the session to revoke"}, {"empty token", []string{"--session-token", " "}, "agv_env", "", "--session-token was given but is empty"}, - {"not an id", []string{"agv_x"}, "", "", "is not a session ID"}, + {"a token as the argument", []string{"agv_secretvalue"}, "", "", "looks like a session token, not a session ID"}, + {"not an id", []string{"secret-ish-value"}, "", "", "the argument is not a session ID"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -142,6 +143,11 @@ func TestValidateAgentVaultRevokeTarget(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("err %v, want %q", err, tc.wantErr) } + for _, arg := range cmd.Flags().Args() { + if strings.Contains(err.Error(), arg) { + t.Fatalf("the error echoes the argument %q: %v", arg, err) + } + } }) } } @@ -205,8 +211,8 @@ func TestRevokeAgentVaultSessionByTokenIgnoresTheDomainInARepoFile(t *testing.T) if auth != "Bearer agv_x" || config.INFISICAL_URL != srv.URL+"/api" { t.Fatalf("the token went to %s (auth %q)", config.INFISICAL_URL, auth) } - if want := "Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and using " + srv.URL + "."; !strings.Contains(warnings.String(), want) { - t.Fatalf("warned %q", warnings.String()) + if warnings.Len() != 0 { + t.Fatalf("an Agent Vault command does not read the file, so has nothing to warn about: %q", warnings.String()) } } diff --git a/packages/cmd/root.go b/packages/cmd/root.go index a60d7fbf0..53601f0e9 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -106,6 +106,12 @@ func resolveDomain(cmd *cobra.Command, flagValue string) string { return envDomain } + // Agent Vault commands send session tokens and mint sessions, and a .infisical.yaml can arrive with a cloned + // repo, so they use only a domain the user chose: the flag, the variables, or the logged-in profile. + if isAgentVaultCommand(cmd) { + return flagValue + } + domain, valid := util.GetDomainFromFile() if domain == "" { return flagValue @@ -122,6 +128,15 @@ func resolveDomain(cmd *cobra.Command, flagValue string) string { return domain } +func isAgentVaultCommand(cmd *cobra.Command) bool { + for current := cmd; current != nil; current = current.Parent() { + if current == agentVaultCmd { + return true + } + } + return false +} + // Commands that manage profiles/sessions themselves print their own outcome, // so the ambient "using profile X" notice would just be noise for them. var profileNoticeExemptCommands = map[string]bool{ diff --git a/packages/cmd/root_domain_test.go b/packages/cmd/root_domain_test.go new file mode 100644 index 000000000..9f87d6004 --- /dev/null +++ b/packages/cmd/root_domain_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" +) + +func useRepoFileDomain(t *testing.T, domain string) { + t.Helper() + for _, env := range util.DomainEnvNames { + t.Setenv(env, "") + } + repo := t.TempDir() + content := "version: 1\ngeneral:\n domain: " + domain + "\n" + if err := os.WriteFile(filepath.Join(repo, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) +} + +func TestResolveDomainIgnoresTheRepoFileForAgentVaultCommands(t *testing.T) { + useRepoFileDomain(t, "https://evil.example") + const fallback = "https://app.infisical.com/api" + + for _, cmd := range []*cobra.Command{agentVaultCmd, agentVaultProxyCmd, agentVaultSessionsRevokeCmd} { + var stderr bytes.Buffer + cmd.SetErr(&stderr) + got := resolveDomain(cmd, fallback) + cmd.SetErr(nil) + if got != fallback { + t.Fatalf("%s: resolved %s, want %s", cmd.CommandPath(), got, fallback) + } + if stderr.Len() != 0 { + t.Fatalf("%s: warned %q", cmd.CommandPath(), stderr.String()) + } + } +} + +func TestResolveDomainStillReadsTheRepoFileForOtherCommands(t *testing.T) { + useRepoFileDomain(t, "https://eu.infisical.com") + + var stderr bytes.Buffer + cmd := &cobra.Command{Use: "secrets"} + cmd.SetErr(&stderr) + if got := resolveDomain(cmd, "https://app.infisical.com/api"); got != "https://eu.infisical.com" { + t.Fatalf("resolved %s", got) + } + if !strings.Contains(stderr.String(), "Using domain 'https://eu.infisical.com' from .infisical.yaml") { + t.Fatalf("warned %q", stderr.String()) + } +} From 8b8d3a74d972a64762ca34d9caaa98d0fedb668a Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:03:39 +0530 Subject: [PATCH 05/11] docs(agent-vault): say where the profile's domain applies --- packages/cmd/root.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cmd/root.go b/packages/cmd/root.go index 53601f0e9..22d1ecce3 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -107,7 +107,8 @@ func resolveDomain(cmd *cobra.Command, flagValue string) string { } // Agent Vault commands send session tokens and mint sessions, and a .infisical.yaml can arrive with a cloned - // repo, so they use only a domain the user chose: the flag, the variables, or the logged-in profile. + // repo, so they skip it. The domain comes from the flag, the variables, or the logged-in profile (applied when the + // login loads). if isAgentVaultCommand(cmd) { return flagValue } From a797db9ce5e5e138ebcd108471ba861c6c361763 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:24:34 +0530 Subject: [PATCH 06/11] fix(agent-vault): stop when .infisical.yaml names another server, and say a session can be for another project --- packages/agentvault/cache.go | 2 +- packages/agentvault/proxy.go | 6 +- .../agentvault/proxy_tunnel_errors_test.go | 2 +- packages/agentvault/socks5_e2e_test.go | 4 +- packages/agentvault/upstream_auth_test.go | 2 +- packages/cmd/root.go | 33 ++-- packages/cmd/root_domain_test.go | 143 ++++++++++++++++-- 7 files changed, 161 insertions(+), 31 deletions(-) diff --git a/packages/agentvault/cache.go b/packages/agentvault/cache.go index 3ee8efb6e..1beb17f9b 100644 --- a/packages/agentvault/cache.go +++ b/packages/agentvault/cache.go @@ -192,7 +192,7 @@ func recentLocked(entries map[string]refusedEntry, key string) (refusedEntry, bo return refusedEntry{}, false } -var errSessionGone = errors.New("session is no longer valid") +var errSessionGone = errors.New("session is no longer valid, or it belongs to a different Agent Vault project than this proxy") // The name the server puts on a 401 that is about this proxy's own token rather than the session it asked // about. Without it the two are the same status and the same error class. diff --git a/packages/agentvault/proxy.go b/packages/agentvault/proxy.go index 9ed20109c..f7904fdb2 100644 --- a/packages/agentvault/proxy.go +++ b/packages/agentvault/proxy.go @@ -213,8 +213,8 @@ func classifyGateRefusal(err error) gateRefusal { "agent-vault: refused, Infisical rejected this proxy's token"} } if isSessionGone(err) { - return gateRefusal{log.Warn(), decisionBlocked, http.StatusForbidden, "the session is no longer valid", - "agent-vault: refused, the session is no longer valid"} + return gateRefusal{log.Warn(), decisionBlocked, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy", + "agent-vault: refused, the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"} } return gateRefusal{log.Error().Err(err), decisionError, http.StatusBadGateway, "failed to resolve the session", "agent-vault: refused, the session could not be resolved"} @@ -643,7 +643,7 @@ func (ps *proxyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, t tun case isProxyTokenRejected(err): decision, status, body = decisionError, http.StatusServiceUnavailable, proxyRevokedBody case isSessionGone(err): - decision, status, body = decisionBlocked, http.StatusForbidden, "the session is no longer valid" + decision, status, body = decisionBlocked, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy" case errors.Is(err, errSessionResolve): decision, status, body = decisionError, http.StatusBadGateway, "failed to resolve the session" case err != nil: diff --git a/packages/agentvault/proxy_tunnel_errors_test.go b/packages/agentvault/proxy_tunnel_errors_test.go index 6a0742a9c..82bcd77cc 100644 --- a/packages/agentvault/proxy_tunnel_errors_test.go +++ b/packages/agentvault/proxy_tunnel_errors_test.go @@ -66,7 +66,7 @@ func TestASessionLostInsideTheTunnelIsA403WithTheGateText(t *testing.T) { if status != http.StatusForbidden { t.Fatalf("a dead session inside the tunnel got %d, which SDKs retry; want 403", status) } - if body != "the session is no longer valid" { + if body != "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy" { t.Fatalf("body %q differs from what the CONNECT gate says", body) } } diff --git a/packages/agentvault/socks5_e2e_test.go b/packages/agentvault/socks5_e2e_test.go index 03163b7db..6b6244a41 100644 --- a/packages/agentvault/socks5_e2e_test.go +++ b/packages/agentvault/socks5_e2e_test.go @@ -251,7 +251,7 @@ func TestSocks5RefusesAtLogin(t *testing.T) { // Only a session that is gone fails the login; a problem on the proxy's side fails the request. wantAuth, wantRep byte }{ - {"revoked or expired session", &api.APIError{StatusCode: 401, Name: infisicalUnauthorizedName}, "the session is no longer valid", socks5AuthStatusRefus, 0}, + {"revoked or expired session", &api.APIError{StatusCode: 401, Name: infisicalUnauthorizedName}, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy", socks5AuthStatusRefus, 0}, {"proxy token rejected", &api.APIError{StatusCode: 401, Name: proxyTokenRejectedName}, "Infisical rejected this proxy's token", socks5AuthStatusOK, socks5RepFailure}, {"Infisical unreachable", errors.New("dial tcp: connection refused"), "the session could not be resolved", socks5AuthStatusOK, socks5RepFailure}, {"Infisical rate limited", &api.APIError{StatusCode: 429}, "the session could not be resolved", socks5AuthStatusOK, socks5RepFailure}, @@ -351,7 +351,7 @@ func TestSocks5RefusalWarningsAreRateLimited(t *testing.T) { if n := strings.Count(out, `"level":"warn","door":"socks5"`); n != 1 { t.Fatalf("got %d warn lines for 4 refusals in a minute, want 1: %s", n, out) } - if n := strings.Count(out, "the session is no longer valid"); n != 4 { + if n := strings.Count(out, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"); n != 4 { t.Fatalf("got %d refusal lines, want each one at warn or debug: %s", n, out) } } diff --git a/packages/agentvault/upstream_auth_test.go b/packages/agentvault/upstream_auth_test.go index 8bad79718..d3c36b5e1 100644 --- a/packages/agentvault/upstream_auth_test.go +++ b/packages/agentvault/upstream_auth_test.go @@ -363,7 +363,7 @@ func TestARefreshThatEndsTheSessionIsAnsweredAsARevokedSession(t *testing.T) { status int body string }{ - {"session revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: infisicalUnauthorizedName}, http.StatusForbidden, "the session is no longer valid"}, + {"session revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: infisicalUnauthorizedName}, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"}, {"proxy token revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: proxyTokenRejectedName}, http.StatusServiceUnavailable, proxyRevokedBody}, } for _, tc := range cases { diff --git a/packages/cmd/root.go b/packages/cmd/root.go index 22d1ecce3..c3c3c6de6 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -97,36 +97,45 @@ func Execute() { // resolveDomain picks the domain by precedence: --domain flag > env > .infisical.yaml > default (flagValue). // Must run after flag parsing (PersistentPreRun, not init) so cmd.Flags().Changed is reliable. -func resolveDomain(cmd *cobra.Command, flagValue string) string { +func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { if cmd.Flags().Changed("domain") { - return flagValue + return flagValue, nil } if envDomain, ok := util.GetEnvDomain(); ok { - return envDomain + return envDomain, nil } + domain, valid := util.GetDomainFromFile() + // Agent Vault commands send session tokens and mint sessions, and a .infisical.yaml can arrive with a cloned // repo, so they skip it. The domain comes from the flag, the variables, or the logged-in profile (applied when the - // login loads). + // login loads). A file that names another instance stops the command instead, so a user who relied on it is not + // sent to the profile's instance without knowing. if isAgentVaultCommand(cmd) { - return flagValue + effectiveDomain := util.ActiveProfileDomain() + if effectiveDomain == "" { + effectiveDomain = flagValue + } + if domain != "" && valid && util.AppendAPIEndpoint(effectiveDomain) != util.AppendAPIEndpoint(domain) { + return "", fmt.Errorf(".infisical.yaml sets the domain %s, but Agent Vault commands don't read it. Pass --domain %s or set INFISICAL_DOMAIN.", domain, domain) + } + return flagValue, nil } - domain, valid := util.GetDomainFromFile() if domain == "" { - return flagValue + return flagValue, nil } if !valid { util.PrintWarningWithWriter("The 'general.domain' field in .infisical.yaml (or 'domain' in a legacy .infisical.json) is not a valid URL (must be an http:// or https:// URL with a host). It will be ignored.", cmd.ErrOrStderr()) - return flagValue + return flagValue, nil } // A .infisical.yaml is usually committed to the repo, so a malicious one could redirect requests // and credentials. Always surface where traffic is going (even under --silent); it goes to stderr. util.PrintWarningWithWriter(fmt.Sprintf("Using domain '%s' from .infisical.yaml (or a legacy .infisical.json); all requests and credentials will be sent there.", domain), cmd.ErrOrStderr()) - return domain + return domain, nil } func isAgentVaultCommand(cmd *cobra.Command) bool { @@ -206,7 +215,11 @@ func init() { _, envDomainSet := util.GetEnvDomain() config.INFISICAL_DOMAIN_EXPLICITLY_SET = cmd.Flags().Changed("domain") || envDomainSet - config.INFISICAL_URL = util.AppendAPIEndpoint(resolveDomain(cmd, config.INFISICAL_URL)) + domain, err := resolveDomain(cmd, config.INFISICAL_URL) + if err != nil { + util.HandleError(err) + } + config.INFISICAL_URL = util.AppendAPIEndpoint(domain) if !util.IsRunningInDocker() && !silent && !isStructuredOutputRequested(cmd) { util.CheckForUpdateWithWriter(cmd.ErrOrStderr()) diff --git a/packages/cmd/root_domain_test.go b/packages/cmd/root_domain_test.go index 9f87d6004..de59f3324 100644 --- a/packages/cmd/root_domain_test.go +++ b/packages/cmd/root_domain_test.go @@ -2,11 +2,14 @@ package cmd import ( "bytes" + "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" + "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/spf13/cobra" ) @@ -24,20 +27,134 @@ func useRepoFileDomain(t *testing.T, domain string) { t.Chdir(repo) } -func TestResolveDomainIgnoresTheRepoFileForAgentVaultCommands(t *testing.T) { - useRepoFileDomain(t, "https://evil.example") +func useProfileDomain(t *testing.T, domain string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the home folder is not HOME here") + } + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, util.CONFIG_FOLDER_NAME) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + content, err := json.Marshal(models.ConfigFile{LoggedInUserDomain: domain}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, util.CONFIG_FILE_NAME), content, 0o600); err != nil { + t.Fatal(err) + } +} + +var agentVaultDomainTestCommands = []*cobra.Command{agentVaultCmd, agentVaultProxyCmd, agentVaultSessionsRevokeCmd} + +func resolveAgentVaultDomain(t *testing.T, cmd *cobra.Command, flagValue string) (string, error) { + t.Helper() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + defer cmd.SetErr(nil) + got, err := resolveDomain(cmd, flagValue) + if stderr.Len() != 0 { + t.Fatalf("%s: warned %q", cmd.CommandPath(), stderr.String()) + } + return got, err +} + +func TestResolveDomainStopsAgentVaultCommandsWhenTheRepoFileNamesAnotherInstance(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + const want = ".infisical.yaml sets the domain https://eu.infisical.com, but Agent Vault commands don't read it. Pass --domain https://eu.infisical.com or set INFISICAL_DOMAIN." + + for _, cmd := range agentVaultDomainTestCommands { + if _, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api"); err == nil || err.Error() != want { + t.Fatalf("%s: got error %v, want %q", cmd.CommandPath(), err, want) + } + } +} + +func TestResolveDomainLetsTheFlagWinOverTheRepoFileForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + + cmd := &cobra.Command{Use: "proxy"} + cmd.Flags().String("domain", "", "") + agentVaultCmd.AddCommand(cmd) + t.Cleanup(func() { agentVaultCmd.RemoveCommand(cmd) }) + if err := cmd.Flags().Set("domain", "https://self-hosted.example.com"); err != nil { + t.Fatal(err) + } + + got, err := resolveAgentVaultDomain(t, cmd, "https://self-hosted.example.com") + if err != nil || got != "https://self-hosted.example.com" { + t.Fatalf("resolved %s, %v", got, err) + } +} + +func TestResolveDomainLetsTheVariableWinOverTheRepoFileForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(util.INFISICAL_DOMAIN_ENV_NAME, "https://self-hosted.example.com") + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api") + if err != nil || got != "https://self-hosted.example.com" { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainAllowsAgentVaultCommandsWhenTheRepoFileMatchesTheProfile(t *testing.T) { + useProfileDomain(t, "https://eu.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com/") + const fallback = "https://eu.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainComparesTheRepoFileWithTheDefaultWhenNoOneIsLoggedIn(t *testing.T) { + useProfileDomain(t, "") + useRepoFileDomain(t, "https://app.infisical.com") const fallback = "https://app.infisical.com/api" - for _, cmd := range []*cobra.Command{agentVaultCmd, agentVaultProxyCmd, agentVaultSessionsRevokeCmd} { - var stderr bytes.Buffer - cmd.SetErr(&stderr) - got := resolveDomain(cmd, fallback) - cmd.SetErr(nil) - if got != fallback { - t.Fatalf("%s: resolved %s, want %s", cmd.CommandPath(), got, fallback) + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) } - if stderr.Len() != 0 { - t.Fatalf("%s: warned %q", cmd.CommandPath(), stderr.String()) + } +} + +func TestResolveDomainIgnoresAnInvalidRepoFileDomainForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "not-a-url") + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainUsesTheProfileForAgentVaultCommandsWithoutARepoFile(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + for _, env := range util.DomainEnvNames { + t.Setenv(env, "") + } + t.Chdir(t.TempDir()) + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) } } } @@ -48,8 +165,8 @@ func TestResolveDomainStillReadsTheRepoFileForOtherCommands(t *testing.T) { var stderr bytes.Buffer cmd := &cobra.Command{Use: "secrets"} cmd.SetErr(&stderr) - if got := resolveDomain(cmd, "https://app.infisical.com/api"); got != "https://eu.infisical.com" { - t.Fatalf("resolved %s", got) + if got, err := resolveDomain(cmd, "https://app.infisical.com/api"); err != nil || got != "https://eu.infisical.com" { + t.Fatalf("resolved %s, %v", got, err) } if !strings.Contains(stderr.String(), "Using domain 'https://eu.infisical.com' from .infisical.yaml") { t.Fatalf("warned %q", stderr.String()) From 0f89d3b4baa22fb6e0003174161663ec0e53aff9 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:56:02 +0530 Subject: [PATCH 07/11] fix(agent-vault): send Agent Vault requests to the logged-in instance --- packages/cmd/root.go | 6 +++--- packages/cmd/root_domain_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cmd/root.go b/packages/cmd/root.go index ca2aa174b..e80d845f1 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -122,8 +122,8 @@ func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { domain, valid := util.GetDomainFromFile() // Agent Vault commands send session tokens and mint sessions, and a .infisical.yaml can arrive with a cloned - // repo, so they skip it. The domain comes from the flag, the variables, or the logged-in profile (applied when the - // login loads). A file that names another instance stops the command instead, so a user who relied on it is not + // repo, so they skip it. The domain comes from the flag, the variables, or the logged-in profile, which is returned + // here so paths that never load the login (proxy tokens, client secrets) use it too. A file that names another instance stops the command instead, so a user who relied on it is not // sent to the profile's instance without knowing. if isAgentVaultCommand(cmd) { effectiveDomain := util.ActiveProfileDomain() @@ -133,7 +133,7 @@ func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { if domain != "" && valid && util.AppendAPIEndpoint(effectiveDomain) != util.AppendAPIEndpoint(domain) { return "", fmt.Errorf(".infisical.yaml sets the domain %s, but Agent Vault commands don't read it. Pass --domain %s or set INFISICAL_DOMAIN.", domain, domain) } - return flagValue, nil + return effectiveDomain, nil } if domain == "" { diff --git a/packages/cmd/root_domain_test.go b/packages/cmd/root_domain_test.go index de59f3324..5b4ef60f5 100644 --- a/packages/cmd/root_domain_test.go +++ b/packages/cmd/root_domain_test.go @@ -107,11 +107,11 @@ func TestResolveDomainLetsTheVariableWinOverTheRepoFileForAgentVaultCommands(t * func TestResolveDomainAllowsAgentVaultCommandsWhenTheRepoFileMatchesTheProfile(t *testing.T) { useProfileDomain(t, "https://eu.infisical.com/api") useRepoFileDomain(t, "https://eu.infisical.com/") - const fallback = "https://eu.infisical.com/api" + const fallback = "https://app.infisical.com/api" for _, cmd := range agentVaultDomainTestCommands { got, err := resolveAgentVaultDomain(t, cmd, fallback) - if err != nil || got != fallback { + if err != nil || got != "https://eu.infisical.com/api" { t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) } } @@ -144,7 +144,7 @@ func TestResolveDomainIgnoresAnInvalidRepoFileDomainForAgentVaultCommands(t *tes } func TestResolveDomainUsesTheProfileForAgentVaultCommandsWithoutARepoFile(t *testing.T) { - useProfileDomain(t, "https://app.infisical.com/api") + useProfileDomain(t, "https://eu.infisical.com/api") for _, env := range util.DomainEnvNames { t.Setenv(env, "") } @@ -153,7 +153,7 @@ func TestResolveDomainUsesTheProfileForAgentVaultCommandsWithoutARepoFile(t *tes for _, cmd := range agentVaultDomainTestCommands { got, err := resolveAgentVaultDomain(t, cmd, fallback) - if err != nil || got != fallback { + if err != nil || got != "https://eu.infisical.com/api" { t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) } } From fa6f110e4d8a924c2646c5588b8011f763c9d2ed Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:10:46 +0530 Subject: [PATCH 08/11] fix(agent-vault): never echo a pasted token, and keep the project note quiet under --silent and -o --- packages/cmd/agent_vault_project.go | 7 +++-- packages/cmd/agent_vault_project_test.go | 39 ++++++++++++++++++++++++ packages/cmd/agent_vault_sessions.go | 4 +-- packages/cmd/agent_vault_skills.go | 2 +- packages/util/cli_error.go | 2 +- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go index 9eaee1094..27ead153c 100644 --- a/packages/cmd/agent_vault_project.go +++ b/packages/cmd/agent_vault_project.go @@ -71,13 +71,16 @@ func agentVaultProjectFrom(value, source string) (agentVaultProject, error) { value = strings.TrimSpace(value) // uuid.Parse also takes the braced and urn: spellings, which the server would refuse. if _, err := uuid.Parse(value); err != nil || len(value) != 36 { - return agentVaultProject{}, fmt.Errorf("%q from %s is not a project ID; copy the ID from the Agent Vault project's settings in Infisical", value, source) + return agentVaultProject{}, fmt.Errorf("%s from %s is not a project ID; copy the ID from the Agent Vault project's settings in Infisical", quoted(value, "project ID"), source) } return agentVaultProject{ID: strings.ToLower(value), Source: source}, nil } // A flag was typed for this run; anything else is ambient, so the command says which project it picked up. -func echoAgentVaultProject(project agentVaultProject) { +func echoAgentVaultProject(cmd *cobra.Command, project agentVaultProject) { + if silent, _ := cmd.Flags().GetBool("silent"); silent || isStructuredOutputRequested(cmd) { + return + } if !project.FromFlag { util.PrintlnStderr(fmt.Sprintf("Agent Vault project %s from %s", project.ID, project.Source)) } diff --git a/packages/cmd/agent_vault_project_test.go b/packages/cmd/agent_vault_project_test.go index 4e99345af..974c6a5fc 100644 --- a/packages/cmd/agent_vault_project_test.go +++ b/packages/cmd/agent_vault_project_test.go @@ -200,3 +200,42 @@ func TestAgentVaultProjectSourceKind(t *testing.T) { } } } + +func TestResolveAgentVaultProjectNeverPrintsATokenPastedAsTheProject(t *testing.T) { + isolateAgentVaultProject(t) + // The shape the server mints: agv_ then 32 random bytes in base64url. + const token = "agv_Xq3vR8kLmN2pQ7sT1uW4yZ6aB9cD0eF5gH-jK_oPqRs" + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, token) + + _, err := resolveAgentVaultProject(projectIDCmd(t)) + if err == nil || strings.Contains(err.Error(), token) || !strings.Contains(err.Error(), " from "+util.INFISICAL_PROJECT_ID_NAME) { + t.Fatalf("got %v", err) + } +} + +func TestEchoAgentVaultProjectStaysQuietUnderSilentAndStructuredOutput(t *testing.T) { + project := agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME} + newCmd := func(args ...string) *cobra.Command { + cmd := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + cmd.Flags().Bool("silent", false, "") + cmd.Flags().StringP("output", "o", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd + } + + for _, args := range [][]string{{"--silent"}, {"-o", "json"}, {"-o", "yaml"}} { + stderr := captureStderr(t) + echoAgentVaultProject(newCmd(args...), project) + if stderr.Len() != 0 { + t.Fatalf("%v: printed %q", args, stderr.String()) + } + } + + stderr := captureStderr(t) + echoAgentVaultProject(newCmd(), project) + if !strings.Contains(stderr.String(), projectFromProjEnv) { + t.Fatalf("without --silent or -o: printed %q", stderr.String()) + } +} diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index 568c2b48b..51981d177 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -59,7 +59,7 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { } // Checked before the session exists, so a typo never leaves one behind with its token unprinted. if outputFormat != "" && outputFormat != util.OUTPUT_FORMAT_JSON && outputFormat != util.OUTPUT_FORMAT_YAML { - util.HandleError(fmt.Errorf("--output must be json or yaml, got %q; leave it out to print only the token", outputFormat)) + util.HandleError(fmt.Errorf("--output must be json or yaml, got %s; leave it out to print only the token", quoted(outputFormat, "format"))) } accessBundles, bundleSource, err := resolveAgentVaultAccessBundles(cmd) @@ -77,7 +77,7 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err) } - echoAgentVaultProject(project) + echoAgentVaultProject(cmd, project) ttl, _ := cmd.Flags().GetString("ttl") label, _ := cmd.Flags().GetString("label") diff --git a/packages/cmd/agent_vault_skills.go b/packages/cmd/agent_vault_skills.go index 14bc76305..a7ddde574 100644 --- a/packages/cmd/agent_vault_skills.go +++ b/packages/cmd/agent_vault_skills.go @@ -44,7 +44,7 @@ INFISICAL_PROJECT_ID, in that order.`, if err != nil { util.HandleError(err) } - echoAgentVaultProject(project) + echoAgentVaultProject(cmd, project) identity := resolveAgentVaultIdentityToken(cmd) httpClient, err := util.GetRestyClientWithCustomHeaders() diff --git a/packages/util/cli_error.go b/packages/util/cli_error.go index 50aa008e2..5b5455254 100644 --- a/packages/util/cli_error.go +++ b/packages/util/cli_error.go @@ -44,7 +44,7 @@ var ( credentialLooking = regexp.MustCompile(`\b(?:st\.[A-Za-z0-9._-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+)`) // Third-party token formats that would otherwise pass as a secret name or slug. Each is matched by // its prefix and length, so names that only start the same way (npm_config_registry) still print. - knownTokenFormat = regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{36}|(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,})\b`) + knownTokenFormat = regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{36}|(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|agv_[A-Za-z0-9_-]{43})\b`) ) func isKnownToken(match string) bool { From 2a490c4c735956f4e3e93ca6a8cfe46cc7c15116 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:29:08 +0530 Subject: [PATCH 09/11] fix(agent-vault): structured errors for sessions create, and examples that start with infisical --- CLI.md | 9 +- packages/cmd/agent_vault_errors.go | 89 ++++++++ packages/cmd/agent_vault_errors_test.go | 218 ++++++++++++++++++++ packages/cmd/agent_vault_project.go | 11 +- packages/cmd/agent_vault_run.go | 2 +- packages/cmd/agent_vault_sessions.go | 41 ++-- packages/cmd/secrets_manager_errors.go | 1 + packages/cmd/secrets_manager_errors_test.go | 4 +- 8 files changed, 345 insertions(+), 30 deletions(-) create mode 100644 packages/cmd/agent_vault_errors.go create mode 100644 packages/cmd/agent_vault_errors_test.go diff --git a/CLI.md b/CLI.md index 4bd53e6e0..6c60febaa 100644 --- a/CLI.md +++ b/CLI.md @@ -58,7 +58,7 @@ With `-o json`, the same error is JSON on stderr: ### How to return one -- Return a `*util.CLIError` (`packages/util/cli_error.go`) from one of the constructors in `packages/cmd/secrets_manager_errors.go`, and print it with `exitWithError(cmd, err)`. That function picks text, JSON, or YAML from `-o`, and exits `1`. +- Return a `*util.CLIError` (`packages/util/cli_error.go`) from one of the constructors in `packages/cmd/secrets_manager_errors.go` (Agent Vault's are in `packages/cmd/agent_vault_errors.go`), and print it with `exitWithError(cmd, err)`. That function picks text, JSON, or YAML from `-o`, and exits `1`. - Pass API errors through `classifyAPIError(err, scope, notFound)`. It uses only what is stable: the status code, the error names the backend guarantees (`TokenError`, `PermissionDenied`, `ProjectMembershipNotFound`), and, for a 404, the `notFound` error the caller passes when the request had one subject (an environment by slug, a project by ID). `scope` is the `errorScope` the command was working on. A 403 `ProjectMembershipNotFound` becomes `project_not_found` for the project `scope` names, so someone who isn't a member is told the same as for a project that doesn't exist, and the project's ID isn't echoed back. It never reads the message text, which the backend can reword at any time. Any other error comes back unchanged and prints as it always did, including the backend's own message. - A backend error that can only be told apart by its message (a slug that's taken, delete protection, a plan limit) has no code until the backend gives it a stable error name. Never match on message text to add one. - Build every value in a suggested command with `util.SuggestedValue` (or `util.SuggestedDomain` for `--domain`). Anything that isn't a UUID, slug, secret name, or folder path becomes a ``. @@ -70,7 +70,7 @@ With `-o json`, the same error is JSON on stderr: | `permission_denied` | The role doesn't allow the action (403) | | `project_not_linked` | No `--projectId`, no `INFISICAL_PROJECT_ID`, and no `.infisical.yaml` | | `project_config_invalid` | The directory's `.infisical.yaml` (or a legacy `.infisical.json`) exists but can't be read. Suggests `infisical init` | -| `project_not_found` | The project doesn't exist, or the caller isn't a member of it (404, or 403 `ProjectMembershipNotFound`) | +| `project_not_found` | The project doesn't exist, or the caller isn't a member of it (404, or 403 `ProjectMembershipNotFound`). For Agent Vault, also a project that isn't an Agent Vault one | | `project_type_not_supported` | A project of another product was passed to a `secrets-manager` command | | `environment_not_found` | The environment slug doesn't exist in the project (404) | | `environment_exists` | `environments create` (or `update --slug`) with a slug an active environment has. Nothing is written | @@ -80,7 +80,10 @@ With `-o json`, the same error is JSON on stderr: | `token_project_mismatch` | `secrets create` or `update` with a service token for a different project than the one selected. Nothing is written | | `invalid_argument` | An argument the command can't use, such as the same secret name given twice to `secrets create`, or a secret with no name or no value, or a name that starts with a number. Nothing is written | | `project_delete_protected` | `projects delete` on a project with delete protection on, read from its settings. Nothing is deleted | -| `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml` | +| `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml`. For Agent Vault, no project from `--project-id`, `INFISICAL_AGENT_VAULT_PROJECT_ID` or `INFISICAL_PROJECT_ID` | +| `invalid_project_id` | An Agent Vault project ID from `--project-id` or a variable isn't a project ID | +| `access_bundle_required` | `agent-vault sessions create` without `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` | +| `invalid_access_bundle` | An access bundle name from `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` isn't a valid name. Nothing is sent | | `secret_exists` | `secrets create` on a key that exists. Nothing is written | | `secret_not_found` | `secrets update` on a key that doesn't exist. Nothing is written | | `folder_not_found` | `folders get`, `update` or `delete` on a folder that doesn't exist at `--path` | diff --git a/packages/cmd/agent_vault_errors.go b/packages/cmd/agent_vault_errors.go new file mode 100644 index 000000000..7a1121941 --- /dev/null +++ b/packages/cmd/agent_vault_errors.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" +) + +const ( + errCodeAccessBundleRequired = "access_bundle_required" + errCodeInvalidAccessBundle = "invalid_access_bundle" + errCodeInvalidProjectID = "invalid_project_id" +) + +func errAgentVaultInvalidOutput(format string) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: fmt.Sprintf("--output must be json or yaml, not %s: leave it out to print only the token.", quoted(format, "format"))} +} + +func errAgentVaultAccessBundleRequired() *util.CLIError { + return &util.CLIError{Code: errCodeAccessBundleRequired, + Message: fmt.Sprintf("An access bundle is required: pass --access-bundle or set %s.", agentVaultAccessBundleEnv)} +} + +func errAgentVaultEmptyAccessBundle() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: `--access-bundle was given but names no bundle: pass a name like "coding-agent".`} +} + +func errAgentVaultOneAccessBundle() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: "A session carries one access bundle: pass --access-bundle once."} +} + +func errAgentVaultInvalidAccessBundle(name string, source agentVaultSource) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidAccessBundle, + Message: fmt.Sprintf(`%s from %s is not an access bundle name: names are lowercase letters, numbers and hyphens, for example "coding-agent".`, + quoted(name, "access bundle"), agentVaultSourceName(source, "--access-bundle", agentVaultAccessBundleEnv))} +} + +// An empty --project-id is usually "$PROJECT" with the variable unset, so it never falls through to a +// project the operator did not name. +func errAgentVaultEmptyProjectID() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: fmt.Sprintf("--project-id was given but is empty: pass the Agent Vault project's ID, or drop the flag and set %s.", agentVaultProjectIDEnv)} +} + +func errAgentVaultProjectRequired() *util.CLIError { + return &util.CLIError{Code: errCodeProjectRequired, + Message: fmt.Sprintf("No Agent Vault project is set: pass --project-id, or set %s or %s.", agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME)} +} + +func errAgentVaultInvalidProjectID(value, source string) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidProjectID, + Message: fmt.Sprintf("%s from %s is not a project ID: copy the ID from the Agent Vault project's settings in Infisical.", quoted(value, "project ID"), source)} +} + +// INFISICAL_PROJECT_ID is often already set for Secrets Management, and the server answers 404 for a project +// that is not an Agent Vault one, so a project from there gets the hint to use the Agent Vault variable. +func errAgentVaultProjectNotFound(project agentVaultProject, cause error) *util.CLIError { + message := fmt.Sprintf("Agent Vault project %s from %s doesn't exist, or you don't have access to it.", quoted(project.ID, "project ID"), project.Source) + if project.Source == util.INFISICAL_PROJECT_ID_NAME { + message += fmt.Sprintf(" %s may name a Secrets Management project: set %s or pass --project-id with the Agent Vault project's ID.", + util.INFISICAL_PROJECT_ID_NAME, agentVaultProjectIDEnv) + } else { + message += " Copy the ID from the Agent Vault project's settings in Infisical." + } + return &util.CLIError{Code: errCodeProjectNotFound, Cause: cause, Message: message} +} + +// classifyAgentVaultAPIError is classifyAPIError for a request scoped to one Agent Vault project. A 404 from a +// route the backend doesn't have yet is left alone, so an older Infisical still prints its own answer. +func classifyAgentVaultAPIError(err error, project agentVaultProject) error { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return err + } + if apiErr.StatusCode == http.StatusForbidden && apiErr.Name == "ProjectMembershipNotFound" { + return errAgentVaultProjectNotFound(project, err) + } + var notFound func(cause error) *util.CLIError + if !isInfisicalRouteMissing(err) { + notFound = func(cause error) *util.CLIError { return errAgentVaultProjectNotFound(project, cause) } + } + return classifyAPIError(err, errorScope{ProjectID: project.ID}, notFound) +} diff --git a/packages/cmd/agent_vault_errors_test.go b/packages/cmd/agent_vault_errors_test.go new file mode 100644 index 000000000..8da781536 --- /dev/null +++ b/packages/cmd/agent_vault_errors_test.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/telemetry" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func agentVaultConstructedErrors() []*util.CLIError { + cause := errors.New("cause") + return []*util.CLIError{ + errAgentVaultInvalidOutput("xml"), + errAgentVaultAccessBundleRequired(), + errAgentVaultEmptyAccessBundle(), + errAgentVaultOneAccessBundle(), + errAgentVaultInvalidAccessBundle("Coding Agent", agentVaultSourceFlag), + errAgentVaultEmptyProjectID(), + errAgentVaultProjectRequired(), + errAgentVaultInvalidProjectID("my-project", "--project-id"), + errAgentVaultProjectNotFound(agentVaultProject{ID: projectFromFlag, Source: "--project-id"}, cause), + errAgentVaultProjectNotFound(agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME}, cause), + } +} + +// runAgentVaultSessionsCreateForTest runs `infisical agent-vault sessions create ` on a fresh command, with +// an identity token from the environment, against an API that answers every request with status and the error +// name apiName. setup runs after the environment is reset. It returns stderr, whether the command exited 1, and +// how many requests reached the API. +func runAgentVaultSessionsCreateForTest(t *testing.T, setup func(), status int, apiName string, args ...string) (stderr string, exited bool, requests int) { + t.Helper() + isolateAgentVaultProject(t) + t.Setenv(agentVaultAccessBundleEnv, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Setenv(util.INFISICAL_TOKEN_NAME, "identity-access-token") + if setup != nil { + setup() + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"statusCode":%d,"error":%q,"message":"anything","reqId":"req-1"}`, status, apiName) + })) + t.Cleanup(srv.Close) + oldURL := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + + errOut := captureStderr(t) + oldExit, oldTelemetry := exitProcess, Telemetry + exitProcess = func(int) { panic(exitCalled{}) } + Telemetry = telemetry.NewTelemetry(false) + t.Cleanup(func() { config.INFISICAL_URL, exitProcess, Telemetry = oldURL, oldExit, oldTelemetry }) + + cmd := &cobra.Command{Use: "create", Run: runAgentVaultSessionsCreate} + cmd.Flags().Bool("silent", false, "") + cmd.Flags().String("project-id", "", "") + cmd.Flags().StringArray("access-bundle", nil, "") + cmd.Flags().String("ttl", "never", "") + cmd.Flags().String("label", "", "") + cmd.Flags().String("client-id", "", "") + cmd.Flags().String("client-secret", "", "") + cmd.Flags().StringP("output", "o", "", "") + if err := cmd.ParseFlags(args); err != nil { + t.Fatal(err) + } + + func() { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(exitCalled); !ok { + panic(r) + } + exited = true + } + }() + cmd.Run(cmd, cmd.Flags().Args()) + }() + return errOut.String(), exited, requests +} + +type cliErrorFields struct { + Code string `json:"code" yaml:"code"` + Message string `json:"message" yaml:"message"` + Next []string `json:"next" yaml:"next"` +} + +func decodeCLIError(t *testing.T, format, stderr string) cliErrorFields { + t.Helper() + var body struct { + Error cliErrorFields `json:"error" yaml:"error"` + } + var err error + if format == util.OUTPUT_FORMAT_JSON { + err = json.Unmarshal([]byte(stderr), &body) + } else { + err = yaml.Unmarshal([]byte(stderr), &body) + } + if err != nil || body.Error.Code == "" { + t.Fatalf("stderr is not a %s error (%v):\n%s", format, err, stderr) + } + return body.Error +} + +func TestAgentVaultSessionsCreateErrorsAreStructured(t *testing.T) { + withProject := func(args ...string) []string { return append(args, "--project-id", projectFromFlag) } + cases := []struct { + name string + args []string + setup func() + status int + apiName string + code string + message string + }{ + {name: "no access bundle", args: withProject(), code: errCodeAccessBundleRequired, message: agentVaultAccessBundleEnv}, + {name: "empty access bundle", args: withProject("--access-bundle", " "), code: errCodeInvalidFlagValue, message: "names no bundle"}, + {name: "two access bundles", args: withProject("--access-bundle", "a", "--access-bundle", "b"), code: errCodeInvalidFlagValue, message: "one access bundle"}, + {name: "invalid access bundle", args: withProject("--access-bundle", "Coding_Agent"), code: errCodeInvalidAccessBundle, message: `"Coding_Agent" from --access-bundle`}, + {name: "invalid access bundle from the environment", args: withProject(), + setup: func() { t.Setenv(agentVaultAccessBundleEnv, "Coding_Agent") }, code: errCodeInvalidAccessBundle, message: "from " + agentVaultAccessBundleEnv}, + {name: "no project", args: []string{"--access-bundle", "coding-agent"}, code: errCodeProjectRequired, message: agentVaultProjectIDEnv}, + {name: "empty project", args: []string{"--access-bundle", "coding-agent", "--project-id", " "}, code: errCodeInvalidFlagValue, message: "--project-id was given but is empty"}, + {name: "not a project ID", args: []string{"--access-bundle", "coding-agent", "--project-id", "my-project"}, code: errCodeInvalidProjectID, message: `"my-project" from --project-id`}, + {name: "project 404", args: withProject("--access-bundle", "coding-agent"), status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: projectFromFlag}, + {name: "project 404 from INFISICAL_PROJECT_ID", args: []string{"--access-bundle", "coding-agent"}, + setup: func() { t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) }, + status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: "may name a Secrets Management project"}, + {name: "not a member", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "ProjectMembershipNotFound", code: errCodeProjectNotFound}, + {name: "permission denied", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "PermissionDenied", code: errCodePermissionDenied}, + {name: "token rejected", args: withProject("--access-bundle", "coding-agent"), status: 401, apiName: "UnauthorizedError", code: errCodeAuthRequired}, + } + for _, format := range []string{util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + for _, c := range cases { + t.Run(format+"/"+c.name, func(t *testing.T) { + status := c.status + if status == 0 { + status = http.StatusInternalServerError + } + stderr, exited, requests := runAgentVaultSessionsCreateForTest(t, c.setup, status, c.apiName, append(c.args, "-o", format)...) + if !exited { + t.Fatalf("want an exit, got stderr=%s", stderr) + } + got := decodeCLIError(t, format, stderr) + if got.Code != c.code || !strings.Contains(got.Message, c.message) { + t.Fatalf("want %s containing %q, got %s %q", c.code, c.message, got.Code, got.Message) + } + if c.status == 0 && requests != 0 { + t.Fatalf("a refused input should send nothing, sent %d requests", requests) + } + }) + } + } +} + +// An unknown -o value is the error being reported, so it can't pick the format: the error is text, and no +// session is minted whose token would go unprinted. +func TestAgentVaultSessionsCreateRefusesAnUnknownOutputAsText(t *testing.T) { + stderr, exited, requests := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", + "--access-bundle", "coding-agent", "--project-id", projectFromFlag, "-o", "xml") + if !exited || requests != 0 { + t.Fatalf("exited=%v requests=%d", exited, requests) + } + if want := `--output must be json or yaml, not "xml": leave it out to print only the token.` + "\n"; stderr != want { + t.Fatalf("got %q, want %q", stderr, want) + } +} + +func TestAgentVaultSessionsCreateTextErrorsKeepTheirMessage(t *testing.T) { + stderr, exited, _ := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", "--project-id", projectFromFlag) + if want := "An access bundle is required: pass --access-bundle or set " + agentVaultAccessBundleEnv + ".\n"; !exited || stderr != want { + t.Fatalf("got %q, want %q", stderr, want) + } +} + +// A token pasted into --access-bundle or --project-id never reaches the error, in any format. +func TestAgentVaultSessionsCreateErrorsNeverRepeatAPastedToken(t *testing.T) { + const token = "agv_Xq3vR8kLmN2pQ7sT1uW4yZ6aB9cD0eF5gH-jK_oPqRs" + for _, args := range [][]string{ + {"--access-bundle", token, "--project-id", projectFromFlag}, + {"--access-bundle", "coding-agent", "--project-id", token}, + } { + for _, format := range []string{"", util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + withFormat := args + if format != "" { + withFormat = append(append([]string{}, args...), "-o", format) + } + stderr, exited, _ := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", withFormat...) + if !exited || strings.Contains(stderr, token) || strings.Contains(stderr, "Xq3vR8") { + t.Errorf("%v -o %s: got %q", args, format, stderr) + } + } + } +} + +// An older Infisical without the route answers 404 for it, which says nothing about the project. +func TestClassifyAgentVaultAPIErrorLeavesAMissingRouteAlone(t *testing.T) { + project := agentVaultProject{ID: projectFromFlag, Source: "--project-id"} + missingRoute := apiError(404, infisicalRouteMissingName, "Route POST:/api/v1/agent-vault/sessions not found") + if got := classifyAgentVaultAPIError(missingRoute, project); got != missingRoute { + t.Errorf("a missing route should print as before, got %v", got) + } + if got := classifyAgentVaultAPIError(apiError(400, "BadRequest", "No access bundle named 'x'"), project); codeOf(got) != "" { + t.Errorf("a 400 is told apart only by its message, so it keeps no code, got %v", got) + } +} diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go index 27ead153c..58a2c5230 100644 --- a/packages/cmd/agent_vault_project.go +++ b/packages/cmd/agent_vault_project.go @@ -31,10 +31,8 @@ func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { if err != nil { return agentVaultProject{}, fmt.Errorf("unable to read --project-id: %w", err) } - // Same reasoning as the empty --session-token: "$PROJECT" with the variable unset should not fall - // through to a project the operator did not name. if strings.TrimSpace(value) == "" { - return agentVaultProject{}, fmt.Errorf("--project-id was given but is empty; pass the Agent Vault project's ID, or drop the flag and set %s", agentVaultProjectIDEnv) + return agentVaultProject{}, errAgentVaultEmptyProjectID() } project, err := agentVaultProjectFrom(value, "--project-id") project.FromFlag = true @@ -46,14 +44,11 @@ func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { return agentVaultProject{}, err } if !found { - return agentVaultProject{}, fmt.Errorf( - "no Agent Vault project is set. Pass --project-id, or set %s or %s", - agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME) + return agentVaultProject{}, errAgentVaultProjectRequired() } return project, nil } -// resolveAgentVaultProjectFromEnvironment is every source but the flag, for commands that have none. func resolveAgentVaultProjectFromEnvironment() (project agentVaultProject, found bool, err error) { if value := strings.TrimSpace(os.Getenv(agentVaultProjectIDEnv)); value != "" { project, err = agentVaultProjectFrom(value, agentVaultProjectIDEnv) @@ -71,7 +66,7 @@ func agentVaultProjectFrom(value, source string) (agentVaultProject, error) { value = strings.TrimSpace(value) // uuid.Parse also takes the braced and urn: spellings, which the server would refuse. if _, err := uuid.Parse(value); err != nil || len(value) != 36 { - return agentVaultProject{}, fmt.Errorf("%s from %s is not a project ID; copy the ID from the Agent Vault project's settings in Infisical", quoted(value, "project ID"), source) + return agentVaultProject{}, errAgentVaultInvalidProjectID(value, source) } return agentVaultProject{ID: strings.ToLower(value), Source: source}, nil } diff --git a/packages/cmd/agent_vault_run.go b/packages/cmd/agent_vault_run.go index 67a1f7009..63c076831 100644 --- a/packages/cmd/agent_vault_run.go +++ b/packages/cmd/agent_vault_run.go @@ -205,7 +205,7 @@ func resolveAgentVaultAccessBundles(cmd *cobra.Command) (bundles []string, sourc return nil, agentVaultSourceNone, err } if bundles = nonBlank(values); len(bundles) == 0 { - return nil, agentVaultSourceFlag, fmt.Errorf("--access-bundle was given but names no bundle; pass a name like 'coding-agent'") + return nil, agentVaultSourceFlag, errAgentVaultEmptyAccessBundle() } return bundles, agentVaultSourceFlag, nil } diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index 51981d177..7d98bffa4 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -33,10 +33,13 @@ var agentVaultSessionsCreateCmd = &cobra.Command{ The session belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or INFISICAL_PROJECT_ID, in that order. -Only the token is printed, so it can be captured into a variable. Pass -o json or -o yaml for the session's -ID, project, access bundle, label and expiry as well. The session stays active until it expires or you revoke -it with 'infisical agent-vault sessions revoke'.`, - Example: ` export INFISICAL_AGENT_VAULT_SESSION_TOKEN=$(infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h) +Only the token is printed, so it can be captured into a variable: + + export INFISICAL_AGENT_VAULT_SESSION_TOKEN=$(infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h) + +Pass -o json or -o yaml for the session's ID, project, access bundle, label and expiry as well. The session +stays active until it expires or you revoke it with 'infisical agent-vault sessions revoke'.`, + Example: ` infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h infisical agent-vault sessions create --project-id 4f0c... --access-bundle coding-agent --label "nightly deploy" -o json`, DisableFlagsInUseLine: true, Args: cobra.NoArgs, @@ -59,23 +62,23 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { } // Checked before the session exists, so a typo never leaves one behind with its token unprinted. if outputFormat != "" && outputFormat != util.OUTPUT_FORMAT_JSON && outputFormat != util.OUTPUT_FORMAT_YAML { - util.HandleError(fmt.Errorf("--output must be json or yaml, got %s; leave it out to print only the token", quoted(outputFormat, "format"))) + exitWithError(cmd, errAgentVaultInvalidOutput(outputFormat)) } accessBundles, bundleSource, err := resolveAgentVaultAccessBundles(cmd) if err != nil { - util.HandleError(err) + exitWithError(cmd, err) } if bundleSource == agentVaultSourceNone { - util.HandleError(fmt.Errorf("an access bundle is required; pass --access-bundle or set %s", agentVaultAccessBundleEnv)) + exitWithError(cmd, errAgentVaultAccessBundleRequired()) } if err := validateAgentVaultAccessBundles(accessBundles, bundleSource); err != nil { - util.HandleError(err) + exitWithError(cmd, err) } project, err := resolveAgentVaultProject(cmd) if err != nil { - util.HandleError(err) + exitWithError(cmd, err) } echoAgentVaultProject(cmd, project) @@ -85,7 +88,11 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { stdoutIsTerminal := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) identity := resolveAgentVaultIdentity(cmd, stdoutIsTerminal) - minted := mintAgentVaultSessionOrExit(identity, project, accessBundles, ttl, label) + minted, err := mintAgentVaultSession(newAgentVaultAPIClient(identity), project, accessBundles, ttl, label) + if err != nil { + exitWithError(cmd, classifyAgentVaultAPIError(err, project), + append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) + } Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) Telemetry.CaptureEvent("cli-command:agent-vault sessions create", posthog.NewProperties(). @@ -100,7 +107,7 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { fmt.Println(minted.Token) return } - formatted, err := util.FormatOutput(outputFormat, agentVaultSessionOutputFrom(*minted, project, accessBundles[0]), nil) + formatted, err := util.FormatOutput(outputFormat, agentVaultSessionOutputFrom(minted, project, accessBundles[0]), nil) if err != nil { util.HandleError(err, "Unable to format the session") } @@ -233,12 +240,11 @@ func agentVaultRevokeErrorMessages(ctx context.Context, err error, byToken bool) // The variable names one bundle and the flag is repeatable only so that passing it twice is refused by name. func validateAgentVaultAccessBundles(bundles []string, source agentVaultSource) error { if len(bundles) > 1 { - return errors.New("a session carries one access bundle; pass --access-bundle once") + return errAgentVaultOneAccessBundle() } for _, name := range bundles { if !agentVaultBundleNameRe.MatchString(name) { - return fmt.Errorf("%q from %s is not an access bundle name; names are lowercase letters, numbers and hyphens, for example 'coding-agent'", - name, agentVaultSourceName(source, "--access-bundle", agentVaultAccessBundleEnv)) + return errAgentVaultInvalidAccessBundle(name, source) } } return nil @@ -257,14 +263,17 @@ func mintAgentVaultSession(httpClient *resty.Client, project agentVaultProject, return created.Session, nil } -func mintAgentVaultSessionOrExit(identity string, project agentVaultProject, accessBundles []string, ttl, label string) *api.AgentVaultSession { +func newAgentVaultAPIClient(identity string) *resty.Client { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { util.HandleError(err, "Failed to build the API client") } httpClient.SetAuthToken(identity) + return httpClient +} - minted, err := mintAgentVaultSession(httpClient, project, accessBundles, ttl, label) +func mintAgentVaultSessionOrExit(identity string, project agentVaultProject, accessBundles []string, ttl, label string) *api.AgentVaultSession { + minted, err := mintAgentVaultSession(newAgentVaultAPIClient(identity), project, accessBundles, ttl, label) if err != nil { util.HandleError(err, append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) } diff --git a/packages/cmd/secrets_manager_errors.go b/packages/cmd/secrets_manager_errors.go index 932b48a1b..76aa51684 100644 --- a/packages/cmd/secrets_manager_errors.go +++ b/packages/cmd/secrets_manager_errors.go @@ -48,6 +48,7 @@ var errorCodes = []string{ errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, errCodeInvalidFlagValue, errCodeProjectDeleteProtected, errCodeInvalidArgument, errCodeServiceTokenUnsupported, errCodeProjectConfigInvalid, errCodeTokenProjectMismatch, errCodeFolderExists, + errCodeAccessBundleRequired, errCodeInvalidAccessBundle, errCodeInvalidProjectID, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName diff --git a/packages/cmd/secrets_manager_errors_test.go b/packages/cmd/secrets_manager_errors_test.go index 013193738..5535ed57a 100644 --- a/packages/cmd/secrets_manager_errors_test.go +++ b/packages/cmd/secrets_manager_errors_test.go @@ -27,7 +27,7 @@ var notYetShippedCommands = []string{} func everyConstructedError() []*util.CLIError { cause := errors.New("cause") action := destructiveAction{Target: `environment "qa"`, Expected: "qa", Label: "the environment slug", Description: "environment qa"} - return []*util.CLIError{ + return append([]*util.CLIError{ errAuthRequired(cause), errPermissionDenied("You are not allowed to create on environments", cause), errProjectNotLinked(), @@ -62,7 +62,7 @@ func everyConstructedError() []*util.CLIError { errNothingToUpdate([]string{"name", "slug"}), errConfirmationRequired(action, "infisical secrets-manager environments delete qa --yes"), errConfirmationDeclined(action), - } + }, agentVaultConstructedErrors()...) } func TestErrorCodesAreStableSnakeCaseAndUnique(t *testing.T) { From fc3e06d93fbafdffd62480eb6f748fb310449692 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:29:51 +0530 Subject: [PATCH 10/11] fix(agent-vault): classify a missing route by its error name, and keep the project ID out of the non-member error --- packages/cmd/agent_vault_errors.go | 16 ++++++++++++++-- packages/cmd/agent_vault_errors_test.go | 8 +++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/cmd/agent_vault_errors.go b/packages/cmd/agent_vault_errors.go index 7a1121941..30957bd5d 100644 --- a/packages/cmd/agent_vault_errors.go +++ b/packages/cmd/agent_vault_errors.go @@ -71,6 +71,16 @@ func errAgentVaultProjectNotFound(project agentVaultProject, cause error) *util. return &util.CLIError{Code: errCodeProjectNotFound, Cause: cause, Message: message} } +// A non-member is told the same as for a project that doesn't exist, without the ID, so the error can't be used to +// confirm one. +func errAgentVaultProjectNotAMember(project agentVaultProject, cause error) *util.CLIError { + return &util.CLIError{ + Code: errCodeProjectNotFound, + Cause: cause, + Message: fmt.Sprintf("The Agent Vault project from %s doesn't exist, or you don't have access to it.", project.Source), + } +} + // classifyAgentVaultAPIError is classifyAPIError for a request scoped to one Agent Vault project. A 404 from a // route the backend doesn't have yet is left alone, so an older Infisical still prints its own answer. func classifyAgentVaultAPIError(err error, project agentVaultProject) error { @@ -79,10 +89,12 @@ func classifyAgentVaultAPIError(err error, project agentVaultProject) error { return err } if apiErr.StatusCode == http.StatusForbidden && apiErr.Name == "ProjectMembershipNotFound" { - return errAgentVaultProjectNotFound(project, err) + return errAgentVaultProjectNotAMember(project, err) } var notFound func(cause error) *util.CLIError - if !isInfisicalRouteMissing(err) { + // The route-missing answer is named "Not Found" and the app's own 404s "NotFound", so the name tells them apart + // without reading the message. + if !(apiErr.StatusCode == http.StatusNotFound && apiErr.Name == infisicalRouteMissingName) { notFound = func(cause error) *util.CLIError { return errAgentVaultProjectNotFound(project, cause) } } return classifyAPIError(err, errorScope{ProjectID: project.ID}, notFound) diff --git a/packages/cmd/agent_vault_errors_test.go b/packages/cmd/agent_vault_errors_test.go index 8da781536..109218b0f 100644 --- a/packages/cmd/agent_vault_errors_test.go +++ b/packages/cmd/agent_vault_errors_test.go @@ -138,7 +138,7 @@ func TestAgentVaultSessionsCreateErrorsAreStructured(t *testing.T) { {name: "project 404 from INFISICAL_PROJECT_ID", args: []string{"--access-bundle", "coding-agent"}, setup: func() { t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) }, status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: "may name a Secrets Management project"}, - {name: "not a member", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "ProjectMembershipNotFound", code: errCodeProjectNotFound}, + {name: "not a member", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "ProjectMembershipNotFound", code: errCodeProjectNotFound, message: "from --project-id doesn't exist"}, {name: "permission denied", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "PermissionDenied", code: errCodePermissionDenied}, {name: "token rejected", args: withProject("--access-bundle", "coding-agent"), status: 401, apiName: "UnauthorizedError", code: errCodeAuthRequired}, } @@ -212,6 +212,12 @@ func TestClassifyAgentVaultAPIErrorLeavesAMissingRouteAlone(t *testing.T) { if got := classifyAgentVaultAPIError(missingRoute, project); got != missingRoute { t.Errorf("a missing route should print as before, got %v", got) } + if got := classifyAgentVaultAPIError(apiError(404, infisicalRouteMissingName, "reworded"), project); got == nil || codeOf(got) != "" { + t.Errorf("a missing route is told apart by its name, not its message, got %v", got) + } + if got := classifyAgentVaultAPIError(apiError(403, "ProjectMembershipNotFound", ""), project); strings.Contains(got.Error(), projectFromFlag) { + t.Errorf("a non-member's error should not repeat the project ID, got %q", got.Error()) + } if got := classifyAgentVaultAPIError(apiError(400, "BadRequest", "No access bundle named 'x'"), project); codeOf(got) != "" { t.Errorf("a 400 is told apart only by its message, so it keeps no code, got %v", got) } From fce6e725c8a7f99ce77cff16d0108bd2458b2ca1 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:42:24 +0530 Subject: [PATCH 11/11] fix(agent-vault): never repeat the repo file's server, let token-only runs through, and code access bundle refusals --- CLI.md | 1 + packages/cmd/agent_vault_errors.go | 20 ++++-- packages/cmd/agent_vault_errors_test.go | 17 +++-- packages/cmd/agent_vault_sessions.go | 2 +- packages/cmd/root.go | 25 ++++++- packages/cmd/root_domain_test.go | 96 ++++++++++++++++++++++++- packages/cmd/secrets_manager_errors.go | 2 +- 7 files changed, 148 insertions(+), 15 deletions(-) diff --git a/CLI.md b/CLI.md index 6c60febaa..8e366efed 100644 --- a/CLI.md +++ b/CLI.md @@ -83,6 +83,7 @@ With `-o json`, the same error is JSON on stderr: | `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml`. For Agent Vault, no project from `--project-id`, `INFISICAL_AGENT_VAULT_PROJECT_ID` or `INFISICAL_PROJECT_ID` | | `invalid_project_id` | An Agent Vault project ID from `--project-id` or a variable isn't a project ID | | `access_bundle_required` | `agent-vault sessions create` without `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` | +| `access_bundle_not_granted` | `agent-vault sessions create` with an access bundle the caller (or the user a session is on behalf of) can't use, or that doesn't exist (400 `AccessBundleNotGranted`) | | `invalid_access_bundle` | An access bundle name from `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` isn't a valid name. Nothing is sent | | `secret_exists` | `secrets create` on a key that exists. Nothing is written | | `secret_not_found` | `secrets update` on a key that doesn't exist. Nothing is written | diff --git a/packages/cmd/agent_vault_errors.go b/packages/cmd/agent_vault_errors.go index 30957bd5d..af066199d 100644 --- a/packages/cmd/agent_vault_errors.go +++ b/packages/cmd/agent_vault_errors.go @@ -10,9 +10,10 @@ import ( ) const ( - errCodeAccessBundleRequired = "access_bundle_required" - errCodeInvalidAccessBundle = "invalid_access_bundle" - errCodeInvalidProjectID = "invalid_project_id" + errCodeAccessBundleRequired = "access_bundle_required" + errCodeAccessBundleNotGranted = "access_bundle_not_granted" + errCodeInvalidAccessBundle = "invalid_access_bundle" + errCodeInvalidProjectID = "invalid_project_id" ) func errAgentVaultInvalidOutput(format string) *util.CLIError { @@ -25,6 +26,11 @@ func errAgentVaultAccessBundleRequired() *util.CLIError { Message: fmt.Sprintf("An access bundle is required: pass --access-bundle or set %s.", agentVaultAccessBundleEnv)} } +func errAgentVaultAccessBundleNotGranted(bundle string, cause error) *util.CLIError { + return &util.CLIError{Code: errCodeAccessBundleNotGranted, Cause: cause, + Message: fmt.Sprintf("Access bundle %s isn't granted to you in this Agent Vault project, or doesn't exist. Check the name, or ask an Agent Vault admin to grant it.", quoted(bundle, "access bundle"))} +} + func errAgentVaultEmptyAccessBundle() *util.CLIError { return &util.CLIError{Code: errCodeInvalidFlagValue, Message: `--access-bundle was given but names no bundle: pass a name like "coding-agent".`} @@ -82,8 +88,9 @@ func errAgentVaultProjectNotAMember(project agentVaultProject, cause error) *uti } // classifyAgentVaultAPIError is classifyAPIError for a request scoped to one Agent Vault project. A 404 from a -// route the backend doesn't have yet is left alone, so an older Infisical still prints its own answer. -func classifyAgentVaultAPIError(err error, project agentVaultProject) error { +// route the backend doesn't have yet is left alone, so an older Infisical still prints its own answer. accessBundle +// is the bundle the request named, or empty when it named none. +func classifyAgentVaultAPIError(err error, project agentVaultProject, accessBundle string) error { var apiErr *api.APIError if !errors.As(err, &apiErr) { return err @@ -91,6 +98,9 @@ func classifyAgentVaultAPIError(err error, project agentVaultProject) error { if apiErr.StatusCode == http.StatusForbidden && apiErr.Name == "ProjectMembershipNotFound" { return errAgentVaultProjectNotAMember(project, err) } + if apiErr.StatusCode == http.StatusBadRequest && apiErr.Name == "AccessBundleNotGranted" && accessBundle != "" { + return errAgentVaultAccessBundleNotGranted(accessBundle, err) + } var notFound func(cause error) *util.CLIError // The route-missing answer is named "Not Found" and the app's own 404s "NotFound", so the name tells them apart // without reading the message. diff --git a/packages/cmd/agent_vault_errors_test.go b/packages/cmd/agent_vault_errors_test.go index 109218b0f..4208bcbb7 100644 --- a/packages/cmd/agent_vault_errors_test.go +++ b/packages/cmd/agent_vault_errors_test.go @@ -24,6 +24,7 @@ func agentVaultConstructedErrors() []*util.CLIError { errAgentVaultEmptyAccessBundle(), errAgentVaultOneAccessBundle(), errAgentVaultInvalidAccessBundle("Coding Agent", agentVaultSourceFlag), + errAgentVaultAccessBundleNotGranted("coding-agent", cause), errAgentVaultEmptyProjectID(), errAgentVaultProjectRequired(), errAgentVaultInvalidProjectID("my-project", "--project-id"), @@ -139,6 +140,8 @@ func TestAgentVaultSessionsCreateErrorsAreStructured(t *testing.T) { setup: func() { t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) }, status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: "may name a Secrets Management project"}, {name: "not a member", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "ProjectMembershipNotFound", code: errCodeProjectNotFound, message: "from --project-id doesn't exist"}, + {name: "access bundle not granted", args: withProject("--access-bundle", "coding-agent"), status: 400, apiName: "AccessBundleNotGranted", + code: errCodeAccessBundleNotGranted, message: `Access bundle "coding-agent" isn't granted to you`}, {name: "permission denied", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "PermissionDenied", code: errCodePermissionDenied}, {name: "token rejected", args: withProject("--access-bundle", "coding-agent"), status: 401, apiName: "UnauthorizedError", code: errCodeAuthRequired}, } @@ -209,16 +212,20 @@ func TestAgentVaultSessionsCreateErrorsNeverRepeatAPastedToken(t *testing.T) { func TestClassifyAgentVaultAPIErrorLeavesAMissingRouteAlone(t *testing.T) { project := agentVaultProject{ID: projectFromFlag, Source: "--project-id"} missingRoute := apiError(404, infisicalRouteMissingName, "Route POST:/api/v1/agent-vault/sessions not found") - if got := classifyAgentVaultAPIError(missingRoute, project); got != missingRoute { + if got := classifyAgentVaultAPIError(missingRoute, project, "x"); got != missingRoute { t.Errorf("a missing route should print as before, got %v", got) } - if got := classifyAgentVaultAPIError(apiError(404, infisicalRouteMissingName, "reworded"), project); got == nil || codeOf(got) != "" { + if got := classifyAgentVaultAPIError(apiError(404, infisicalRouteMissingName, "reworded"), project, "x"); got == nil || codeOf(got) != "" { t.Errorf("a missing route is told apart by its name, not its message, got %v", got) } - if got := classifyAgentVaultAPIError(apiError(403, "ProjectMembershipNotFound", ""), project); strings.Contains(got.Error(), projectFromFlag) { + if got := classifyAgentVaultAPIError(apiError(403, "ProjectMembershipNotFound", ""), project, "x"); strings.Contains(got.Error(), projectFromFlag) { t.Errorf("a non-member's error should not repeat the project ID, got %q", got.Error()) } - if got := classifyAgentVaultAPIError(apiError(400, "BadRequest", "No access bundle named 'x'"), project); codeOf(got) != "" { - t.Errorf("a 400 is told apart only by its message, so it keeps no code, got %v", got) + if got := classifyAgentVaultAPIError(apiError(400, "BadRequest", "No access bundle named 'x'"), project, "x"); codeOf(got) != "" { + t.Errorf("a 400 without a stable name is told apart only by its message, so it keeps no code, got %v", got) + } + notGranted := classifyAgentVaultAPIError(apiError(400, "AccessBundleNotGranted", "No access bundle named 'x' is granted"), project, "x") + if codeOf(notGranted) != errCodeAccessBundleNotGranted || strings.Contains(notGranted.Error(), "is granted") { + t.Errorf("a 400 AccessBundleNotGranted should get its code and keep the server's message out, got %v", notGranted) } } diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go index 7d98bffa4..d6f48ac7d 100644 --- a/packages/cmd/agent_vault_sessions.go +++ b/packages/cmd/agent_vault_sessions.go @@ -90,7 +90,7 @@ func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { identity := resolveAgentVaultIdentity(cmd, stdoutIsTerminal) minted, err := mintAgentVaultSession(newAgentVaultAPIClient(identity), project, accessBundles, ttl, label) if err != nil { - exitWithError(cmd, classifyAgentVaultAPIError(err, project), + exitWithError(cmd, classifyAgentVaultAPIError(err, project, accessBundles[0]), append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) } diff --git a/packages/cmd/root.go b/packages/cmd/root.go index e80d845f1..460ebe29d 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -130,8 +130,8 @@ func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { if effectiveDomain == "" { effectiveDomain = flagValue } - if domain != "" && valid && util.AppendAPIEndpoint(effectiveDomain) != util.AppendAPIEndpoint(domain) { - return "", fmt.Errorf(".infisical.yaml sets the domain %s, but Agent Vault commands don't read it. Pass --domain %s or set INFISICAL_DOMAIN.", domain, domain) + if domain != "" && valid && util.AppendAPIEndpoint(effectiveDomain) != util.AppendAPIEndpoint(domain) && !agentVaultRunNeverContactsInfisical(cmd) { + return "", fmt.Errorf(".infisical.yaml names a server that Agent Vault commands don't read. Pass --domain or set INFISICAL_DOMAIN.") } return effectiveDomain, nil } @@ -151,6 +151,27 @@ func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { return domain, nil } +// A run on a session token with --no-skills mints nothing and installs no skills, so no domain is used and a +// repo file naming another server can't misdirect anything. +func agentVaultRunNeverContactsInfisical(cmd *cobra.Command) bool { + if cmd != agentVaultRunCmd { + return false + } + if noSkills, err := cmd.Flags().GetBool("no-skills"); err != nil || !noSkills { + return false + } + _, tokenSource, err := resolveAgentVaultSessionToken(cmd) + if err != nil { + return false + } + _, bundleSource, err := resolveAgentVaultAccessBundles(cmd) + if err != nil { + return false + } + useToken, _, err := decideAgentVaultSession(tokenSource, bundleSource) + return err == nil && useToken +} + func isAgentVaultCommand(cmd *cobra.Command) bool { for current := cmd; current != nil; current = current.Parent() { if current == agentVaultCmd { diff --git a/packages/cmd/root_domain_test.go b/packages/cmd/root_domain_test.go index 5b4ef60f5..c8e5dcb01 100644 --- a/packages/cmd/root_domain_test.go +++ b/packages/cmd/root_domain_test.go @@ -12,6 +12,7 @@ import ( "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) func useRepoFileDomain(t *testing.T, domain string) { @@ -64,7 +65,7 @@ func resolveAgentVaultDomain(t *testing.T, cmd *cobra.Command, flagValue string) func TestResolveDomainStopsAgentVaultCommandsWhenTheRepoFileNamesAnotherInstance(t *testing.T) { useProfileDomain(t, "https://app.infisical.com/api") useRepoFileDomain(t, "https://eu.infisical.com") - const want = ".infisical.yaml sets the domain https://eu.infisical.com, but Agent Vault commands don't read it. Pass --domain https://eu.infisical.com or set INFISICAL_DOMAIN." + const want = ".infisical.yaml names a server that Agent Vault commands don't read. Pass --domain or set INFISICAL_DOMAIN." for _, cmd := range agentVaultDomainTestCommands { if _, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api"); err == nil || err.Error() != want { @@ -73,6 +74,99 @@ func TestResolveDomainStopsAgentVaultCommandsWhenTheRepoFileNamesAnotherInstance } } +// The file arrives with a cloned repo, so its value is never echoed into a message a user may copy from. +func TestResolveDomainNeverRepeatsTheRepoFileDomainForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + const malicious = "https://evil.example/$(curl x)" + useRepoFileDomain(t, "'"+malicious+"'") + if domain, valid := util.GetDomainFromFile(); domain != malicious || !valid { + t.Fatalf("the file domain should read back as a valid %q, got %q (valid %v)", malicious, domain, valid) + } + + for _, cmd := range agentVaultDomainTestCommands { + _, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api") + if err == nil || strings.Contains(err.Error(), "evil.example") || strings.Contains(err.Error(), "curl") { + t.Fatalf("%s: got error %v", cmd.CommandPath(), err) + } + } +} + +// setAgentVaultRunFlags sets flags on the real run command, which resolveDomain recognizes by identity, and +// resets them when the test ends. +func setAgentVaultRunFlags(t *testing.T, values map[string]string) { + t.Helper() + t.Cleanup(func() { + for _, name := range []string{"session-token", "no-skills", "access-bundle"} { + flag := agentVaultRunCmd.Flags().Lookup(name) + if slice, ok := flag.Value.(pflag.SliceValue); ok { + _ = slice.Replace(nil) + } else { + _ = flag.Value.Set(flag.DefValue) + } + flag.Changed = false + } + }) + for name, value := range values { + if err := agentVaultRunCmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + } +} + +func TestResolveDomainLetsAgentVaultRunOnASessionTokenWithoutSkillsIgnoreTheRepoFile(t *testing.T) { + cases := []struct { + name string + flags map[string]string + tokenEnv string + bundleEnv string + }{ + {name: "token flag", flags: map[string]string{"session-token": "agv_x", "no-skills": "true"}}, + {name: "token variable", flags: map[string]string{"no-skills": "true"}, tokenEnv: "agv_x"}, + {name: "token flag beats a bundle variable", flags: map[string]string{"session-token": "agv_x", "no-skills": "true"}, bundleEnv: "coding-agent"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(agentVaultSessionTokenEnv, c.tokenEnv) + t.Setenv(agentVaultAccessBundleEnv, c.bundleEnv) + setAgentVaultRunFlags(t, c.flags) + + got, err := resolveAgentVaultDomain(t, agentVaultRunCmd, "https://app.infisical.com/api") + if err != nil || got != "https://app.infisical.com/api" { + t.Fatalf("resolved %s, %v", got, err) + } + }) + } +} + +func TestResolveDomainStillStopsAgentVaultRunWhenItWouldContactInfisical(t *testing.T) { + cases := []struct { + name string + flags map[string]string + tokenEnv string + bundleEnv string + }{ + {name: "token without --no-skills", flags: map[string]string{"session-token": "agv_x"}}, + {name: "--no-skills without a token", flags: map[string]string{"no-skills": "true"}}, + {name: "--no-skills minting over a bundle", flags: map[string]string{"access-bundle": "coding-agent", "no-skills": "true"}, tokenEnv: "agv_x"}, + {name: "--no-skills with both variables", flags: map[string]string{"no-skills": "true"}, tokenEnv: "agv_x", bundleEnv: "coding-agent"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(agentVaultSessionTokenEnv, c.tokenEnv) + t.Setenv(agentVaultAccessBundleEnv, c.bundleEnv) + setAgentVaultRunFlags(t, c.flags) + + if _, err := resolveAgentVaultDomain(t, agentVaultRunCmd, "https://app.infisical.com/api"); err == nil { + t.Fatal("want the run stopped") + } + }) + } +} + func TestResolveDomainLetsTheFlagWinOverTheRepoFileForAgentVaultCommands(t *testing.T) { useProfileDomain(t, "https://app.infisical.com/api") useRepoFileDomain(t, "https://eu.infisical.com") diff --git a/packages/cmd/secrets_manager_errors.go b/packages/cmd/secrets_manager_errors.go index 76aa51684..6c468fd1c 100644 --- a/packages/cmd/secrets_manager_errors.go +++ b/packages/cmd/secrets_manager_errors.go @@ -48,7 +48,7 @@ var errorCodes = []string{ errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, errCodeInvalidFlagValue, errCodeProjectDeleteProtected, errCodeInvalidArgument, errCodeServiceTokenUnsupported, errCodeProjectConfigInvalid, errCodeTokenProjectMismatch, errCodeFolderExists, - errCodeAccessBundleRequired, errCodeInvalidAccessBundle, errCodeInvalidProjectID, + errCodeAccessBundleRequired, errCodeInvalidAccessBundle, errCodeInvalidProjectID, errCodeAccessBundleNotGranted, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName