security: harden agent surface — vault_exec allow-list, SSRF, error hygiene

Stress-test remediation across MCP, CLI, and backend:

- primora_vault_exec rewritten as an allow-list (was deny-list):
  colon-form single-token commands bypassed it, leaking every vault
  secret; verbatim env-name injection allowed NODE_OPTIONS=--require
  host RCE. Now fixed read-mostly command set, arg/path deny regex,
  strict env-name grammar, loader-hook name blocklist. All prior
  bypasses verified denied; allowed commands still inject.
- SSRF: webhook/job URL validation now blocks cloud metadata IPs
  (169.254.169.254, fd00:ec2::254) inside the link-local allowance
  and non-canonical IP encodings (decimal/hex bare hosts) smuggled
  as "docker service names".
- Path params encodeURIComponent in all client consumers — encodeURI
  left ?#& intact, smuggling query segments through {name} params.
- primora_upload_object strict-validates contentBase64 — Buffer.from
  silently dropped invalid chars.
- MCP no-creds startup degrades instead of crashing mid-handshake.
- GET /api/v1/context — API-key self-discovery; fixes the CLI dead
  end (login --api-key validates the key + seeds org/project,
  use/context/whoami work under key auth).
- CLI: unknown commands / bare group exit 1 with hints; mri numeric
  coercion reversed via raw-argv restore (007 stays "007");
  login --json; orgs:rm / projects:rm / buckets:rm behind --yes;
  fetch-failed errors name the cause.
- Error hygiene: pgx.ErrNoRows -> 404, pgconn codes -> clean
  400/409 without SQLSTATE leaks, validator failures formatted,
  services.InputError maps input faults to 400 (webhook URL/event
  errors were 500s), presign on local driver -> 400.
- Webhook events accept wildcard patterns (*, ns.*, custom.*)
  matched via SQL LIKE — client-published events can be webhooked.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 21:56:11 +02:00
1 parent 3b90dae767
commit 2d6135121e
28 files changed
+705 -32

No files matched your search

@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
DELETE FROM core.organizations
WHERE id = $1
RETURNING *;
-- name: GetOrganizationByID :one
SELECT * FROM core.organizations
WHERE id = $1;
+4 -1
View File
@@ -11,7 +11,10 @@ WHERE id = $1;
SELECT * FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events));
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
));
-- name: CreateWebhook :one
INSERT INTO core.webhooks (
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
return i, err
}
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT id, slug, name, created_at FROM core.organizations
WHERE id = $1
`
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, getOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at,
@@ -76,6 +76,7 @@ type Querier interface {
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events))
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
))
`
type ListWebhooksForEventParams struct {
+62 -1
View File
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
@@ -11,6 +12,7 @@ import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
@@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/me", h.me)
api.GET("/context", h.actorContext)
api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
@@ -181,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) actorContext(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
@@ -921,6 +937,10 @@ func (h *HTTPHandler) presignObject(c *gin.Context) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
if errors.Is(err, services.ErrPresignUnsupported) {
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
return
}
h.handleError(c, err)
return
}
@@ -1260,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
return false
}
if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
} else {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
}
return false
}
return true
}
func formatFieldError(f validator.FieldError) string {
if f.Tag() == "required" {
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
}
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
case errors.Is(err, pgx.ErrNoRows):
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
default:
var inputErr *services.InputError
if errors.As(err, &inputErr) {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
return
}
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Structured codes — never leak raw SQLSTATE text or constraint names.
switch pgErr.Code {
case "23505":
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
case "23503":
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
case "22001", "54000":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
case "22021":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
default:
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
}
return
}
status := http.StatusInternalServerError
message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected")
}
for _, ok := range [][]string{
{"*"},
{"document.*"},
{"custom.deploy.done"},
{"custom.*"},
{"custom.deploy.*"},
} {
if _, err := validateWebhookEvents(ok); err != nil {
t.Errorf("expected %v accepted, got %v", ok, err)
}
}
for _, bad := range [][]string{
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
} {
if _, err := validateWebhookEvents(bad); err == nil {
t.Errorf("expected %v rejected", bad)
}
}
}
func TestPrivateWebhookHostHardening(t *testing.T) {
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
"8.8.8.8", "example.com", "999999999999"}
for _, h := range private {
if !isPrivateWebhookHost(h) {
t.Errorf("expected %q private", h)
}
}
for _, h := range public {
if isPrivateWebhookHost(h) {
t.Errorf("expected %q public/blocked", h)
}
}
}
@@ -405,6 +405,51 @@ func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models
return nil
}
// ActorContext describes the caller's own scope — the self-discovery
// endpoint for API keys, which cannot call /me. User actors get just the
// type marker; their full view is PlatformSummary.
type ActorContext struct {
Actor string `json:"actor"`
Scopes []string `json:"scopes,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty"`
Organization *ContextOrgInfo `json:"organization,omitempty"`
Project *ContextRef `json:"project,omitempty"`
}
type ContextOrgInfo struct {
ContextRef
Role string `json:"role,omitempty"`
}
type ContextRef struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
out := ActorContext{Actor: string(actor.Type)}
if actor.Type != models.ActorTypeAPIKey {
return out, nil
}
out.Scopes = actor.Scopes
out.KeyPrefix = actor.APIKeyPrefix
if actor.ProjectID == nil {
return out, nil
}
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
if err != nil {
return out, err
}
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
if err != nil {
return out, err
}
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
return out, nil
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
if actor.UserID == nil {
return PlatformSummary{}, errors.New("user actor required")
@@ -1479,6 +1524,20 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil
}
// ErrPresignUnsupported — the configured storage driver can't mint signed
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
// InputError marks a service-layer failure caused by caller input — the
// HTTP layer maps it to 400 instead of 500.
type InputError struct{ msg string }
func (e *InputError) Error() string { return e.msg }
func inputErrorf(format string, args ...any) error {
return &InputError{msg: fmt.Sprintf(format, args...)}
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
@@ -1507,7 +1566,7 @@ func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, fmt.Errorf("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
return nil, ErrPresignUnsupported
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
+48 -5
View File
@@ -14,6 +14,7 @@ import (
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
@@ -165,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return "", errors.New("webhook url must be an https URL")
return "", inputErrorf("webhook url must be an https URL")
}
if u.Scheme == "https" {
return trimmed, nil
@@ -173,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
return trimmed, nil
}
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
}
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
// metadataAddrs are link-local addresses that serve cloud instance
// metadata — "private" by range but a privileged SSRF target.
var metadataAddrs = map[string]bool{
"169.254.169.254": true, // AWS/GCP/Azure
"fd00:ec2::254": true, // AWS IPv6 metadata
}
func isPrivateWebhookHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if metadataAddrs[h] {
return false
}
if h == "localhost" || h == "host.docker.internal" ||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
return true
}
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
return true // bare hostname — docker service name, unqualified LAN host
// Bare hostname — docker service name, unqualified LAN host. A
// pure decimal/hex integer is never a service name: it's a
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
if decimalOrHexIPRe.MatchString(h) {
return false
}
return true
}
ip := net.ParseIP(h)
if ip == nil {
@@ -192,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
}
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
// derived from the known system event types.
func webhookEventNamespaces() map[string]bool {
ns := map[string]bool{}
for t := range webhookEventTypes {
if i := strings.Index(t, "."); i > 0 {
ns[t[:i]] = true
}
}
return ns
}
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
// patterns, and `custom.*` wildcard patterns — the same grammar functions
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
func validateWebhookEvents(events []string) ([]string, error) {
out := make([]string, 0, len(events))
seen := map[string]bool{}
namespaces := webhookEventNamespaces()
for _, e := range events {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !webhookEventTypes[e] {
return nil, fmt.Errorf("unknown event type %q", e)
valid := webhookEventTypes[e] || e == "*"
if !valid && strings.HasSuffix(e, ".*") {
valid = namespaces[strings.TrimSuffix(e, ".*")]
}
if !valid && strings.HasPrefix(e, "custom.") {
// `*` isn't in the type grammar — probe it as a literal.
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
}
if !valid {
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
}
if !seen[e] {
seen[e] = true
+44
View File
@@ -68,6 +68,22 @@ paths:
$ref: "#/components/schemas/MeResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/context:
get:
tags: [Platform]
operationId: getActorContext
summary: Caller scope — self-discovery for API keys, which cannot call /me
security:
- bearerAuth: []
responses:
"200":
description: Actor type, scopes, and resolved org/project for API keys
content:
application/json:
schema:
$ref: "#/components/schemas/ActorContext"
"401":
$ref: "#/components/responses/ErrorResponse"
/instance/public:
get:
tags: [Instance]
@@ -2776,6 +2792,34 @@ components:
items:
$ref: "#/components/schemas/OrganizationSummary"
required: [user, organizations]
ActorContext:
type: object
properties:
actor:
type: string
enum: [user, api_key]
scopes:
type: array
items:
type: string
key_prefix:
type: string
organization:
$ref: "#/components/schemas/ContextRef"
project:
$ref: "#/components/schemas/ContextRef"
required: [actor]
ContextRef:
type: object
properties:
id:
type: string
format: uuid
slug:
type: string
name:
type: string
required: [id, slug, name]
OrganizationMembership:
type: object
properties:
+65 -3
View File
@@ -4,7 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js";
import { cmdDocumentsList } from "./commands/documents.js";
import { cmdEventsSend } from "./commands/events.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdInject } from "./commands/inject.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
@@ -16,8 +16,8 @@ import {
cmdObjectsPresign,
cmdObjectsUpload,
} from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
import {
cmdSecretsGet,
cmdSecretsImport,
@@ -52,6 +52,7 @@ cli
.option("--email <email>", "Account email")
.option("--password <password>", "Account password")
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
.option("--json", "Machine-readable output")
.action(cmdLogin);
cli.command("logout", "Remove stored credentials").action(cmdLogout);
@@ -70,6 +71,10 @@ cli
.command("orgs:create <name>", "Create an organization")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.action(cmdOrgsCreate);
cli
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdOrgsRemove);
cli
.command("projects:list", "List projects in the current org")
@@ -82,6 +87,10 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--description <text>", "Description")
.action(cmdProjectsCreate);
cli
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdProjectsRemove);
cli
.command("buckets:list", "List buckets in the current project")
@@ -94,6 +103,11 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--public", "Public visibility (default private)")
.action(cmdBucketsCreate);
cli
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdBucketsRemove);
cli
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
@@ -331,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv[0] = "secrets:list";
}
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
// non-boolean flag and patch parsed options before the action runs.
function restoreRawOptionStrings(): void {
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
.matchedCommand;
if (!cmd) return;
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
const raw = process.argv.slice(2);
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
for (const spec of specs) {
if (typeof state.options[spec.name] !== "number") continue;
let value: string | undefined;
for (let i = 0; i < raw.length; i++) {
for (const n of spec.names) {
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
for (const f of flags) {
if (raw[i] === f) value = raw[i + 1];
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
}
}
}
if (value !== undefined) state.options[spec.name] = value;
}
}
// Unknown command or a bare group — cac only fires this on non-empty args.
cli.addEventListener("command:*", () => {
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
const arg = String(state.args[0] ?? "");
const group = arg.split(":")[0];
const verbs = GROUPS.has(group)
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
: [];
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
process.stderr.write(" run `primora --help` for usage\n");
process.exit(1);
});
async function main() {
try {
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
restoreRawOptionStrings();
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
if (!state.matchedCommand && !state.options.help && !state.options.version) {
cli.outputHelp();
if (argv.length) process.exit(1);
return;
}
await cli.runMatchedCommand();
} catch (error) {
fail(error);
+37 -2
View File
@@ -10,6 +10,7 @@ interface LoginOptions {
email?: string;
password?: string;
apiKey?: string;
json?: boolean;
}
function cancel(): never {
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
}
cfg.auth = { type: "apiKey", key: options.apiKey };
// Validate the key against the instance before saving — a stored-but-
// dead key is a worse failure than a rejected login.
configureClient(cfg);
const ctx = await PlatformService.getActorContext();
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Saved API key credentials to ${configFilePath()}`);
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
if (isJson(options)) {
printJson(ctx);
return;
}
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
if (ctx.organization && ctx.project) {
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
}
dim(`Config written to ${configFilePath()}`);
return;
}
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
configureClient(cfg);
const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me);
return;
}
success(`Signed in as ${me.user.email}`);
dim(`Config written to ${configFilePath()}`);
}
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig();
configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson(ctx);
return;
}
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
[
`${ctx.key_prefix}…`,
ctx.scopes?.join(", ") || "—",
ctx.organization?.slug ?? "—",
ctx.project?.slug ?? "—",
],
]);
dim(`\n${cfg.baseUrl} · auth: api_key`);
return;
}
const me = await PlatformService.getMe();
if (isJson(options)) {
+21 -2
View File
@@ -1,8 +1,8 @@
import { CreateBucketRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js";
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
export async function cmdBucketsList(options: {
project?: string;
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
}
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
}
export async function cmdBucketsRemove(
bucketRef: string,
options: { project?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const bucketId = await resolveBucketId(projectId, bucketRef);
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
await StorageService.deleteBucket({ bucketId });
if (isJson(options)) {
printJson({ deleted: bucketId });
} else {
success(`deleted bucket ${bucketRef}`);
}
}
+29
View File
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
requireAuth(cfg);
configureClient(cfg);
// API keys are bound to one org+project — resolve the scope directly.
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
}
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
}
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
return;
}
const me = await PlatformService.getMe();
const orgs = me.organizations;
if (orgs.length === 0) {
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
requireAuth(cfg);
configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
return;
}
dim(`url ${cfg.baseUrl}`);
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
return;
}
const me = await PlatformService.getMe();
const org = me.organizations.find((o) => o.id === cfg.organizationId);
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
+28
View File
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
}
success(`Organization "${org.name}" created (${org.slug})`);
}
export async function cmdOrgsRemove(
orgRef: string,
options: { yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let organizationId = orgRef;
if (!isUuid(orgRef)) {
const me = await PlatformService.getMe();
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
organizationId = found.id;
}
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
await OrganizationsService.deleteOrganization({ organizationId });
if (isJson(options)) {
printJson({ deleted: organizationId });
} else {
success(`deleted organization ${orgRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+31
View File
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
}
success(`Project "${project.name}" created (${project.slug})`);
}
export async function cmdProjectsRemove(
projectRef: string,
options: { org?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let projectId = projectRef;
if (!isUuid(projectRef)) {
const me = await PlatformService.getMe();
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
const found = orgs
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
projectId = found.id;
}
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
await ProjectsService.deleteProject({ projectId });
if (isJson(options)) {
printJson({ deleted: projectId });
} else {
success(`deleted project ${projectRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+2
View File
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
OpenAPI.HEADERS = undefined;
OpenAPI.TOKEN = undefined;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
+19 -1
View File
@@ -17,9 +17,27 @@ export function dim(message: string): void {
process.stdout.write(pc.dim(message) + "\n");
}
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
if (yes) return;
if (!process.stdin.isTTY) {
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
}
const answer = await p.confirm({ message });
if (p.isCancel(answer) || !answer) {
p.cancel("Aborted.");
process.exit(1);
}
}
export function fail(error: unknown): never {
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
let message = err?.message ?? "unknown error";
// undici TypeError "fetch failed" — the useful part is the cause code.
if (message === "fetch failed" || message.startsWith("fetch failed")) {
const code = err?.cause?.code;
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
}
// generated client throws ApiError with a parsed body
const body = err?.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) message = body.error.message;
+2
View File
@@ -3,6 +3,8 @@ import { OpenAPI } from "@primora/api-client";
import { fetchApiToken } from "./auth-client";
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
OpenAPI.CREDENTIALS = "include";
OpenAPI.WITH_CREDENTIALS = true;
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
+2
View File
@@ -49,6 +49,8 @@ async function mintSessionToken(cfg: McpConfig): Promise<string> {
export function configureClient(cfg: McpConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
return;
+73 -14
View File
@@ -22,7 +22,16 @@ import {
import { configureClient, loadConfig, type McpConfig } from "./config.js";
const cfg = loadConfig();
configureClient(cfg);
try {
configureClient(cfg);
} catch (e) {
// Missing credentials must not kill the server — initialize/tools/list
// still work, and every call surfaces the actionable error through the
// normal tool error path instead of an uncaught startup exception.
OpenAPI.TOKEN = async () => {
throw e;
};
}
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
@@ -234,7 +243,13 @@ server.registerTool(
const bucketId = await bucketIdFor(projectId(p), bucket);
let blob: Blob;
if (contentBase64 !== undefined) {
blob = new Blob([Buffer.from(contentBase64, "base64")], {
// Buffer.from(..., "base64") silently drops invalid chars — strict-
// validate so corrupt input can't silently store garbage bytes.
const clean = contentBase64.replace(/\s/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) {
throw new Error("contentBase64 is not valid base64");
}
blob = new Blob([Buffer.from(clean, "base64")], {
type: contentType ?? "application/octet-stream",
});
} else if (content !== undefined) {
@@ -939,20 +954,65 @@ server.registerTool(
},
);
// Commands that would move a secret into the response are denied outright —
// the CLI is the broker; the model sees results, not credentials.
const EXEC_DENY_FIRST = new Set(["vault", "secrets", "inject", "agent", "login", "logout", "use"]);
const EXEC_DENY_PAIR = new Set(["keys:create"]);
// vault_exec allow-list — read/non-destructive commands an agent may run
// under an injected secret. Deny-lists lose (a colon-form `secrets:get`
// already bypassed a pair-match once); only these verbs execute.
// Commands touching host files (upload/download --out, secrets import) and
// anything that moves a secret into output are intentionally absent.
const EXEC_ALLOW = new Set([
"whoami",
"context",
"orgs:list",
"projects:list",
"buckets:list",
"objects:list",
"documents:list",
"jobs:list",
"jobs:runs",
"audit:list",
"keys:list",
"events:send",
]);
// Flags that could redirect output into host files — none of the allowed
// commands need them, so reject the whole set defensively.
const EXEC_ARG_DENY = /^--?(out|file|path|dir|env-file|output|write)\b/;
// Env names that must never carry an injected secret — preload/runtime
// hooks and interpreter variables turn an env injection into code exec
// (NODE_OPTIONS, LD_*, BASH_ENV, PYTHONPATH, …). PRIMORA_* stays allowed:
// the injected VALUE comes from the vault, and those names are its purpose.
const ENV_NAME_DENY =
/^(NODE_|LD_|DYLD_|BASH|ENV$|IFS$|PATH$|HOME$|SHELL$|CDPATH$|SHELLOPTS$|BASHOPTS$|PYTHON|PERL|RUBY|GIT_|SSL_CERT|XDG_)/;
function execAllowed(raw: string[]): { args: string[]; verb: string } {
// Normalize colon-form: ["objects:list","b"] ≡ ["objects","list","b"].
const args = [...raw];
if (args[0].includes(":") && !args[0].startsWith("-")) {
args.splice(0, 1, ...args[0].split(":"));
}
const verb = args.length > 1 && !args[1].startsWith("-") ? `${args[0]}:${args[1]}` : args[0];
if (!EXEC_ALLOW.has(verb)) {
throw new Error(`denied: "${verb}" is not on the vault_exec allow-list (read-only commands only)`);
}
for (const a of args) {
if (a.includes("\0")) throw new Error("denied: NUL byte in argument");
if (a.includes("..") || EXEC_ARG_DENY.test(a)) {
throw new Error(`denied: argument "${a}" could redirect to host files`);
}
}
return { args, verb };
}
server.registerTool(
"primora_vault_exec",
{
description:
"Run a primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). The secret never appears in arguments or the response — only the command output is returned.",
"Run a read-only primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). Allow-listed commands only; the secret never appears in arguments or the response.",
inputSchema: {
command: z
.array(z.string())
.describe('primora arguments, e.g. ["objects","list","mybucket"] — vault/secrets/inject/agent/login/logout/use and keys:create are denied'),
.describe('primora arguments, e.g. ["objects","list","mybucket"] — allow-listed read commands only'),
secret: z
.string()
.optional()
@@ -962,17 +1022,16 @@ server.registerTool(
async ({ command, secret }) => {
try {
if (!command.length) throw new Error("empty command");
const first = command[0];
const pair = `${first}:${command[1] ?? ""}`;
if (EXEC_DENY_FIRST.has(first) || EXEC_DENY_PAIR.has(pair) || EXEC_DENY_PAIR.has(first)) {
throw new Error(`denied: "${first}" would expose credentials or config`);
}
const { args } = execAllowed(command);
const secretName = secret ?? "PRIMORA_API_KEY";
if (!/^[A-Z_][A-Z0-9_]{0,63}$/.test(secretName) || ENV_NAME_DENY.test(secretName)) {
throw new Error(`denied: "${secretName}" is not a safe env name to inject`);
}
const got = runCli(["secrets", "get", secretName]);
if (got.code !== 0) {
throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`);
}
const r = runCli(command, { [secretName]: got.stdout.trimEnd() });
const r = runCli(args, { [secretName]: got.stdout.trimEnd() });
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
} catch (e) {
return fail(e);