关于 Portunus
Portunus 是连接 AI Agent 与现有密钥库的密钥代理,供需要让 Agent 调用 API、部署或签名的团队使用。它按元数据查找密钥,并在执行边界注入值,使密钥值不进入 LLM 上下文、日志或工具输出。
主要功能
- 01按元数据查找密钥
- 02在执行边界注入密钥
- 03支持 GCP、本地加密和浏览器凭据
- 04提供 MCP 服务、CLI 和 UI
- 05防篡改哈希链审计日志
- 06可选的按 Agent 访问控制
适用场景
- 01让 Agent 调用 API 而不暴露密钥值
- 02在部署命令中注入凭据
- 03从现有密钥库按环境查找凭据
MCP 相关
Portunus 提供 MCP 服务,包含默认仅返回元数据的工具;密钥解析和注入在执行边界进行。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm ghcr.io/mdostal/portunus:0.34.0本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/mdostal/portunus:0.34.0stdio工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
Portunus
一个秘密信息代理,可根据元数据查找秘密信息并注入它们——让它们完全不会接触大模型。免费且开源。
由 Mathew Dostal 打造——Dostal Technology 的兼职 CTO。
选择你的保险库。为 AI Agent 提供安全的工具来使用它——让秘密信息完全不会接触 大模型。
portunus 是 AI Agent 与你现有秘密信息保险库之间的轻量级代理。它不会存储你的秘密信息,也不会重新发明保险库安全机制——而是根据元数据解析秘密信息,并在边界处注入,使 Agent 能够使用秘密信息(调用 API、部署、签名),同时原始值永远不会进入模型的上下文、日志或工具输出。
(Portunus——罗马神话中的钥匙与门之神。名副其实。)
只需一条命令,让你的 Agent 开始使用
curl -fsSL https://mdostal.github.io/portunus/install.sh | bash
安装 CLI,将 MCP 服务接入机器上已有的 AI 编码 Agent CLI(目前支持 Claude Code、Codex CLI),并添加使用技能——这样 Agent 会立即获得仅在边界处使用秘密信息的约定,而不只是原始工具列表。已经安装了 portunus?只需运行 portunus agent init——效果相同,可幂等执行,随时安全地重复运行(例如,安装新的运行环境后)。portunus agent status 会显示当前已接入的内容,不会做任何更改。
由于它通常会作为独立程序运行——自身带有本地键值存储,而不只是插件——它也会保持自身更新:portunus update check(只读)/ portunus update run(安装,可交互确认或使用 --yes)遵循与桌面应用更新器相同的原则:“使用你自己的、已完成身份验证的 gh CLI,固定到确切的发行版标签,绝不在无人值守时静默替换”。它会在 stderr 中显示一行提示(“portunus vX.Y.Z is available…”),每天最多显示一次,不会自行安装任何内容——可随时通过 PORTUNUS_NO_UPDATE_CHECK=1 选择退出。
portunus 网站 →——在同一处查看落地页、安装片段和组件模型介绍。
文档 →——快速入门、概念、架构、CLI/MCP 参考,以及 流程与设计;这里记录了每项功能背后的设计决策,内容从 .pHive/ 发布,并在每次推送到 main 时重新构建。
问题
AI Agent 需要真正的秘密信息才能完成实际工作——部署令牌、API 密钥、数据库凭据。但秘密信息一旦进入大模型的上下文、日志行或工具结果,就会泄露。大多数团队已经有了保险库,缺少的是让 Agent 安全使用保险库的那一层。
这就是 portunus。
目前可用
- 机密值绝不会进入大模型的上下文、日志或工具输出。 由代码强制保障——每条边界路径(解析器的三个输出端、每个 MCP 工具、每个适配器的故障路径)都有测试覆盖,断言返回值、stdout/stderr 或审计日志中绝不会出现该值;故障路径也不例外,而不只是验证正常情况。
- 按元数据解析。 根据机密的属性来查找,例如
anthropic / demo / prod,而不是靠记住密钥名称。默认拒绝——匹配结果有歧义时会拒绝并列出候选项,绝不会返回错误的机密。 - 在边界注入——环境变量、文件(env/json/yaml)、子进程 argv,或
0600临时文件。Agent 获取的是引用或路径,绝不会获取机密值。 - 自选密钥库——GCP Secret Manager(无密钥,通过 Workload Identity Federation 使用)、本地加密密钥库(Fernet:AES-128-CBC + HMAC-SHA256,无需云端配置),以及 Chrome 自带的已保存登录信息(macOS Keychain / Linux Secret Service-KWallet,只读;参见
docs/backends.md)目前均可用。 - 一个核心,三种入口——MCP 服务(21 个工具,默认仅提供元数据)、35 个命令的 CLI,以及 Next.js UI(另有围绕该 UI 构建的原生 macOS 桌面外壳)。
- 防篡改的哈希链审计日志——以机密名称为索引,绝不记录机密值。
portunus verify可验证该链。 - 按 Agent 控制访问权限,可选启用。
portunus roles set --principal <agent>可将策略限定到特定身份(org/project/env/repo);portunus roles enforce on可启用该策略。未配置策略的范围始终保持完全开放——强制执行只会收窄特定主体可访问的内容,绝不会造成意外锁定。参见下文“访问控制(Petitio)”。
路线图(欢迎贡献)
- 更多密钥库后端——HashiCorp Vault、AWS、Azure、1Password、Doppler、Infisical(目前为默认拒绝的空桩——申请你需要的后端,即可优先处理)
- 原生 HTTP 客户端注入适配器(目前可使用
portunus resolve --exec curl ...,或通过库的resolve_call提供调用方自定义的边界回调——这些方式可用,但目前还没有内置的HttpHeaderAdapter/HttpBodyAdapter类,只有EnvVarAdapter/FileAdapter) - 更多机密轮换适配器(目前提供八种真正可用的适配器,均通过
portunus rotation run运行:OAuth 刷新令牌、GCP 服务账号密钥、Google Cloud API 密钥、Resend API 密钥、Grafana Cloud 访问策略令牌、YouTrack/Hub 永久令牌、Vercel 访问令牌,以及来自你拥有的 App 的 GitHub OAuth App 令牌。GitHub PAT 属于manual,因为 GitHub 没有重新签发 API。Stripe 仍是会抛出异常的空桩。portunus liveness check会标记失效的 GitHub 令牌。各子类型的能力矩阵参见 docs/rotation.md) - 支持范围感知的
list/tree——无论调用方自身的访问范围如何,这两个 MCP 工具目前仍会返回整个密钥库的元数据;只有实际的resolve/注入路径目前受访问控制(这是一个已确认并明确记录的缺口——尚未开始;见 portunus-petitio-rbac 的 design-discussion.md 自我审视) - 审批令牌(
portunus approve)目前只与引用名称绑定,不与请求审批的身份绑定——因此,另一个同时运行的 Agent 可以利用同一个审批窗口(这也是一个已确认并明确记录的缺口,来源相同)
如实说明适用范围
Portunus 保证自身的路径绝不会泄露机密值。但它无法阻止你包装的命令回显自己的参数——echo {{secret}} 会打印机密,因为这是该命令的行为,而不是 portunus 的行为。这是正确且经得起审视的边界,并且已在代码中说明。
在开放中构建
由一人构建,快速迭代。核心功能真实可用且经过测试;后端和轮换机制仍有很大发展空间。欢迎找出问题、指出漏洞、提交 issue、发送 PR——如果你能攻破安全模型,那是 一份厚礼,请开一个 issue。
快速开始
curl -fsSL https://mdostal.github.io/portunus/install.sh | bash # or: pip install -e ".[test]" from a clone
# store a secret -- stdin/file only, never an inline flag (would land in shell history)
echo -n "sk-ant-..." | portunus drop shared-anthropic dostal-shared-anthropic --stdin
portunus state shared-anthropic enabled # lands dropped/fail-closed by default; this makes it injectable
# resolve it only at the boundary -- the value never touches your shell history, a log, or an LLM turn
portunus resolve --exec curl -H "Authorization: Bearer {{secret:shared-anthropic}}" \
https://api.anthropic.com/v1/messages
如需了解完整信息,请参阅下文的安装和用法——包括注册现有密钥,而不是新建一个密钥、基于标签查找、MCP 服务和 UI。
架构、愿景与设计决策
- docs/architecture.md — 面向采用者的参考文档:组件图、以决策树形式展示的 ARCA 后端选择优先级、Petitio 的现状与设计目标、完整的请求/解析流程,以及轮换溯源设计——共五张图,并如实区分真实实现与占位实现。
- 长期方向(摘自本项目自己的规划文档,
.pHive/project-profile.yaml):Portunus 是一个独立、可发布、可封装的密钥查找/管理工具——而不只是 Dostal-harness 插件。[...] GCP Secret Manager 是存储端;Portunus 是其上层的解析器+注入层——这一划分已确定,不再讨论。长远来看,它也应自行声明为 L2 Pantheon 插件 [...],同时在独立运行时提供自己的 harness 集成(skill/CLI)和 UI。
- 每项设计决策及其理由,而不只是最终结果 —
.pHive/epics/中为本项目已交付的每项功能保存了研究简报和设计讨论(通常还经过对抗式「盘问」):例如,为什么如今只有本地和 GCP 是真正的 ARCA 后端,为什么轮换功能最初只提供溯源信息,为什么桌面应用调用gh,而不是内嵌令牌,等等。这是真实的文档记录,而不是精选亮点集锦。 - CHANGELOG.md — 按版本记录已交付的内容。
- 适配器模式 — 新的 ARCA 后端只需实现一个方法
(
access(sm_name, project="") -> str,参见backend.py中的SecretBackend),并在功能真正可用前通过清晰的错误信息安全失败;可提出请求,或参考现有的占位实现(例如InfisicalBackend)。
组件模型
Portunus 是完整的密钥代理系统,而不是其中的任何单个部分。它的各个组件都使用各自的名称(拉丁语风格,并与主题保持一致):
| 组件 | 作用 | 当前所在位置 |
|---|---|---|
| OSTIARIUS | 守门 API——从保险库请求内容或向其中存入内容的唯一方式(请求/存入边界),也包括仅查询元数据的问题,例如“这个项目有哪些密钥”。三个入口,一种实现:portunus CLI、独立 UI 的 API 路由,以及供其他 Agent/运行框架使用的 MCP stdio 服务 | resolver.py + portunus CLI(cli.py)+ mcp_server.py(portunus mcp) |
| ARCA | 保险库存储——通过单一接口接入可插拔后端,并按 Reference/项目实际选择后端(优先使用 Reference 自身的 backend 覆盖项,其次是项目的 VaultBinding,最后使用全局回退项),而非全局统一选择。**目前已实现:**本地加密(默认)、GCP Secret Manager(通过 WIF 实现无密钥访问,可选支持考虑数据新近程度的仅拉取式下行同步缓存;即使发生实际网络中断,也能提供最后一个已知可用的缓存值)、OAuth 刷新令牌代理,以及 Chrome 自带的已保存登录信息(macOS Keychain/Linux Secret Service-KWallet,只读;Windows 暂缓——docs/backends.md)。**诚实的占位实现,尚未实现:**AWS Secrets Manager、HashiCorp Vault、Infisical、Doppler、1Password、Azure Key Vault——每个后端都会以明确错误和请求支持链接安全失败,绝不会悄悄将请求路由到错误位置。完整说明请参阅 docs/architecture.md。 | localvault.py(LocalEncryptedBackend,默认);backend.py(SecretBackend、GcloudBackend、SyncingBackend、VaultBinding 和六个占位类);auth.py(无密钥 WIF/OIDC 凭据签发);discover.py(只读枚举实际服务提供方项目中已有的内容) |
| Petitio | 审批门控封装层——封装每个 OSTIARIUS 请求,确保访问始终经过门控(授予/门控/审批 + 生命周期守护)。按 Agent 控制访问权限已实现,并可选择启用:每次调用都会通过 check_injectable() 传递一个 Identity(requester),后者会依据 roles.py 的 PolicyRecord(org/project/env/repo 范围 + principal)进行评估,评估过程使用 roles.evaluate()——无论如何,每次解析都会记录一条 would-allow/would-deny 审计日志;但只有在明确对该保险库运行过 portunus roles enforce on 时,才会实际抛出 NotAuthorized(默认关闭;未配置任何策略的范围始终保持开放)。尚未支持范围感知的内容:MCP 的 list/tree 工具,以及 approve() 的令牌(令牌绑定到 Reference 名称,而非请求者身份)——请参阅路线图。与此相关但并非同一功能的是:轮换来源记录(rotation.py)会记录哪些服务提供方可以轮换某个 Reference,以及 Portunus 是否有该服务提供方的实际适配器。目前有八个适配器已实现(auto),且都通过 portunus rotation run 运行:OAuth 刷新令牌(OAuthRefreshRotationAdapter)、GCP 服务账号密钥(GCPServiceAccountKeyRotationAdapter)、Google Cloud API 密钥(GoogleCloudAPIKeyRotationAdapter)、Resend API 密钥(ResendRotationAdapter)、Grafana Cloud 组织级访问策略令牌(GrafanaCloudRotationAdapter)、YouTrack/Hub 永久令牌(YouTrackRotationAdapter)、Vercel 访问令牌(VercelRotationAdapter),以及来自你所拥有的 App 的 GitHub OAuth App 令牌(GitHubRotationAdapter;GitHub PAT 属于 manual,因为 GitHub 没有重新签发 API)。Stripe 仍是会抛出错误的占位实现。请参阅 docs/rotation.md。另请参阅 docs/architecture.md §3/§17。 | broker.py、roles.py、rotation.py |
| (审计) | 上述所有功能底层的防篡改哈希链访问日志 | audit.py |
因此:Agent 与 OSTIARIUS 通信;Petitio 决定请求是否可以继续;只有此后 ARCA 才会提供(或接收)值——每项决策都会记录在审计链中。
Portunus 维护一个引用注册表(name -> Secret Manager location,绝不存储值),并且仅在执行边界处才会将 {{secret:NAME}} 占位符解析为实时值——也就是实际的出站 API/工具/构建调用。该调用在模型生成输出之后运行。
不可妥协的原则:LLM/Agent 上下文中绝不能包含明文密钥。 密钥通过名称引用;执行框架会在边界处获取并注入真实值。 模型始终只能看到
{{secret:slack-bot-token}}。
这是一个 Dostal 执行框架插件(type: core,引擎 tool)——首批运行时插件之一,与 Anonymizer(PII)和 Approval 插件并列。Multica/Hive 会注册它的 manifest.json 并关联其 Vault 标签页;Agent 会调用 portunus 工具。
model sees only {{secret:shared-anthropic}}
┌──────────┐ ┌───────────┐ ┌────────────────────┐ ┌──────────────┐
│ ticket/ │──▶│ the LLM │──▶│ PORTUNUS RESOLVE │──▶│ outbound API │
│ agent │ │ (context) │ │ gate + fetch + sub│ │ (real value)│
└──────────┘ └───────────┘ └─────────┬──────────┘ └──────────────┘
│ fetch by name (boundary only)
▼
GCP Secret Manager
安全性说明
- 注册表没有值字段。
Reference记录 SM 名称/路径、作用域、类型、生命周期状态和审批门控——不包含任何密钥。读取、复制和检查都是安全的。 - **解析器绝不会向上传递值。**明文只会流入以下三个边界接收端之一:调用方提供的可调用对象、通过 exec 执行的子进程的 argv,或一个必须由调用方删除的
0600临时文件。它绝不会返回给任意调用方/模型代码,也绝不会写入日志。 - 每次获取都受策略门控。
dropped/revoked引用会以失败关闭方式处理;受门控的引用必须先获得有时限的人类审批,才能进行解析。 - 每次解析都会进行哈希链审计——日志会记录引用/SM 名称和结果,绝不记录值;
portunus verify可证明链未被篡改。
安装
curl -fsSL https://mdostal.github.io/portunus/install.sh | bash # CLI + MCP + agent skills, one shot
# or, from a clone:
pip install -e ".[test]"
目前尚未以此名称发布到 PyPI——PyPI 上的 portunus 是一个无关且已停止维护的包
(IQTLabs/portunus);本项目自己的 PyPI 名称是
pantheon-portunus(安装后的命令仍为 portunus),待正式发布后即可使用。
目前,上面的安装命令会直接从 GitHub 获取。
需要 Python ≥ 3.10。默认后端是本地加密的 ARCA 层级(LocalEncryptedBackend,采用
cryptography 的 Fernet 方案——AES-128-CBC + HMAC-SHA256;我们绝不自行实现密码算法)。主密钥
存放在独立的 0600 文件中,与加密 Vault 文件分开,两者都位于 PORTUNUS_HOME 下(默认为
~/.portunus,权限为 0700)。设置 PORTUNUS_BACKEND=gcloud 可改用 GCP Secret Manager——默认通过 Workload Identity Federation 实现无密钥访问,并支持多个项目(见下文“GCP:多项目与发现”);PORTUNUS_BACKEND=aws 会选择 AWS Secrets Manager 存根(会明确报错——尚未实现);PORTUNUS_BACKEND=mock 用于测试/试运行。
在容器中运行
对于大多数实际部署,这是实际的安装方式——不是“下载桌面应用”,
而是“拉取镜像,并与当前正在部署的内容一同运行”。仓库根目录中的 Dockerfile
会构建 CLI + MCP 服务(不包含 UI——为何如此明确界定 v1 范围,请参见
docs/architecture.md);它无需修改即可用于 Docker 或 Podman。
docker build -t portunus .
docker run --rm -v portunus-home:/home/portunus/.portunus portunus resolve --exec curl \
-H "Authorization: Bearer {{secret:shared-anthropic}}" https://api.anthropic.com/v1/messages
预构建镜像 + MCP Registry。发布标签还会将多架构镜像发布到
ghcr.io/mdostal/portunus:<version>(以及 :latest),并将其列入官方
MCP Registry,名称为 io.github.mdostal/portunus,因此 MCP 客户端可以通过
docker run -i --rm -v portunus-home:/home/portunus/.portunus ghcr.io/mdostal/portunus mcp
运行该服务。在容器中,只有能够从容器内访问存储的后端才可用:本地加密后端
(通过挂载的 PORTUNUS_HOME 卷)和 GCP Secret Manager(使用挂载或注入的凭据)。Chrome 已保存登录信息的后端会读取主机上的 Chrome 配置文件,因此无法通过容器镜像使用。
⚠️ 对于本地加密后端,PORTUNUS_HOME 必须是一个真实、持久的卷。 映像将其声明为 VOLUME,因此即使运行时未挂载卷,也会获得一个持久的匿名卷——但如果该卷被移除(docker rm -v、docker volume rm,或从未挂载卷的临时 CI 运行器),下次启动时会从头重新生成 master.key,所有此前存储的密钥都将永久无法恢复。除了一次性测试,任何情况下都应绑定挂载或指定一个真实卷。若只使用 GCP 后端(PORTUNUS_BACKEND=gcloud),则不受此风险影响——没有本地密文会丢失。
两种真实的部署形态,而不是一个抽象的“容器化”方案:
- CI/构建步骤:就是上面的命令——运行一次性容器,将密钥解析到单条命令的 argv 中,边界层面的保证与在本地运行 CLI 完全相同。
- Kubernetes sidecar:Portunus 容器和你的应用容器共用一个 pod,因此它们天然共用网络命名空间,也可以免费共用一个卷。应用通过
kubectl exec访问 Portunus(或者由 Portunus 自行启动应用:resolve --exec <app-start-command>,与 CI 示例使用的模式相同)——绝不进行网络调用,也不会跨越第二个信任边界。这是有意为 v1 设定的范围,并非疏漏:目前 MCP 服务仅支持 stdio,这使同 pod/同主机可达性成为自然选择;如果要让多个 pod 调用一个可通过网络访问的共享 Portunus 服务,就需要在调用方与密钥代理之间实现真正的网络层身份验证(目前roles.py的访问控制假定调用方是受信任的本地进程,与此设计中所有其他同主机边界相同)——这是实质性更大的工作,明确留待未来完成。
各后端、各环境的身份验证:
| 环境 | 本地加密后端 | GCP 后端 |
|---|---|---|
| 本地 Docker/Podman 开发 | 零配置——首次写入时自动初始化 | 以只读方式挂载主机的 ~/.config/gcloud(-v ~/.config/gcloud:/home/portunus/.config/gcloud:ro),让容器复用你已完成身份验证的 gcloud 身份 |
| 真实 Kubernetes | 挂载真实的 PersistentVolume | GKE Workload Identity(绑定到 GCP 服务账号的 k8s ServiceAccount)——无需密钥,不会有任何凭据进入容器映像或卷。这是推荐的生产路径,而非多个选项之一。 |
仓库根目录中的 docker-compose.yml 是一个可运行的 sidecar 模式示例——docker compose build && docker compose run --rm portunus drop ...——完整操作说明请参阅该文件开头的注释。
Podman 说明。 Podman 默认以 rootless 模式运行,会将容器 UID 重新映射到非特权主机 UID 范围,因此它对绑定挂载的主机目录(而非命名卷)权限的处理可能与 Docker 不同。如果绑定挂载目录无法按预期写入,请尝试 podman run --userns=keep-id ...。命名卷(如上面的示例)则完全避开了此问题——除非你确实需要访问主机路径,否则应优先使用命名卷,而不是绑定挂载主机目录。
用法
将密钥放入 Arca(仅限执行环境)
drop 用于将值放入本地加密保管库——来源可以是标准输入、本地文件或交互式遮蔽提示,绝不使用内联标志(否则会进入 shell 历史记录/ps),也绝不通过 LLM 轮次传递。密钥会以 state=dropped(默认拒绝)状态存放,因此必须单独显式执行 enable,之后才能注入。周围的空白字符(从网页复制令牌或读取由 Windows 创建的文件时经常出现的实际问题)始终会被去除:
portunus drop shared-anthropic dostal-shared-anthropic --value-file /path/to/value # or --stdin
portunus state shared-anthropic enabled # now injectable
portunus state shared-anthropic locked # optional: freeze further changes, still injectable
省略 --stdin 和 --value-file 即可使用交互式遮蔽提示(终端不回显、输入两次,并在两次输入不匹配时拒绝)——此模式供人在自己的终端中操作,不适用于 Agent 的工具调用:Agent 自身的工具执行通道本质上是非交互式的(没有实时 TTY),因此此模式仅对亲自运行命令的人有用:
portunus drop shared-anthropic dostal-shared-anthropic
value:
value (confirm):
注册对带外密钥的引用(名称 → Secret Manager 位置)
当值已存在于后端时(例如已有的 GCP 密钥),请使用此功能代替 drop:
portunus reg add shared-anthropic dostal-shared-anthropic --scope shared --kind anthropic
portunus reg show
在 Arca 中存储浏览器/登录会话状态
Arca 可以在站点/账号命名空间下持久化 Playwright 风格的 storageState 或其他可序列化为 JSON 的会话对象。原始保管库会在 session:<site>:<account> 下存储一个加密 Blob;检查时只会返回 TTL 和轮换元数据,不会返回 Cookie、令牌或本地存储值:
from portunus import LocalEncryptedBackend
vault = LocalEncryptedBackend()
vault.store_session(
"example.test",
"[email protected]",
storage_state,
ttl_seconds=3600,
rotation_interval_seconds=900,
)
metadata = vault.inspect_session("example.test", "[email protected]")
record = vault.load_session("example.test", "[email protected]") # raises SessionExpired past its TTL
也可通过 CLI 使用 — session load 遵循与 resolve 完全相同的边界原则:它会写入一个权限为 0600 的临时文件,并且只打印路径,绝不打印记录:
portunus session store example.test [email protected] --value-file storage_state.json --ttl-seconds 3600
portunus session inspect example.test [email protected] # metadata only
portunus session list # every stored session, metadata only
path=$(portunus session load example.test [email protected]) # fails closed once expired (--allow-expired to override)
# ... use "$path" ...
rm -f "$path"
portunus session remove example.test [email protected]
访问控制:session store 上的 --org/--project/--env/--repo 是可选的范围元数据,session load 会通过与所有其他引用相同的 roles.evaluate() 策略引擎进行门控 — 会话与密钥值一样敏感。默认不配置(未设置范围,或没有匹配的策略 = 始终允许,与其他功能未配置时默认放行的行为相同);只有 session load 受门控,store/remove/inspect/list 均不受门控,这与 resolve 只对受门控的获取操作进行检查的既有做法一致。
桥接到真实的 Playwright 浏览器上下文:session load 打印的路径中保存的是完整记录(命名空间/TTL/轮换/范围以及负载),而不是单独的 storageState 格式 — 一行代码即可将其解包:
import json
from playwright.sync_api import sync_playwright
record = json.load(open(path)) # $path from `session load`, above
storage_state = record["session"] # the bare Playwright storageState dict
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context(storage_state=storage_state) # or write it back out and pass a path
无需也不存在单独的注入模块 — browser.new_context(storage_state=...) 已可直接接受字典(或接受写入文件的字典路径),因此这一行解包代码就是完整的桥接。已在真实 Chromium 上下文中进行实时验证,并非对合成 JSON 格式的断言 — 请参阅 docs/architecture.md §21。
查找、注入并询问 — 按元数据标签查找及边界注入
引用除了旧有的 scope/kind 外,还带有结构化标签(provider/project/env,以及开放的 tags{} 字典)。按标签查找采用失败即关闭策略:匹配结果为零个或多个时,始终返回明确错误,绝不猜测。
# Find a reference by tags -- metadata only, never a value:
portunus find --tags provider=vercel,project=mdostal.com,env=prod
# Resolve by tags and inject directly at a boundary target:
portunus inject --tags provider=vercel,project=mdostal.com,env=prod --target env --var VERCEL_TOKEN
portunus inject --tags provider=vercel,project=mdostal.com,env=prod --target file --format env --key VERCEL_TOKEN --path ./.env.local
# Semantic front door -- plain-language request instead of exact tags:
portunus ask "the vercel secret for mdostal.com in prod" --target env --var VERCEL_TOKEN
portunus ask "the vercel secret for mdostal.com in prod" # resolve-only preview, no injection
ask 同样绝不猜测:对于无法识别或有歧义的请求,它会在 stderr 上提出澄清问题,而不是自行选择。.claude/skills/ 下有三个轻量 Claude 技能(也安装在 Claude Code 的用户范围内,因此机器上的任何会话都能使用,而不只是此仓库中的会话),用于封装 CLI/MCP 接口,以便 Agent 调用工具:portunus-ask(按描述获取/注入)、portunus-drop(存储刚收到的单个或批量新密钥),以及 portunus-vault-setup(配置/检查项目的后端和同步模式,并强制同步)。
ask 也能识别添加/轮换相关表达(“add”/“create”/“new secret”、“rotate”/“roll”/“regenerate”),并将其转为请求,而不是直接执行 — Agent 在任何时候都不会提供或看到值:
# Requires explicit --name/--tags -- free text can't safely name/tag something brand new:
portunus ask "add a new secret" --name gh-ci-token --tags provider=github,project=portunus,env=prod
# Flags an existing reference for rotation (metadata only, value untouched):
portunus ask "rotate the vercel secret for mdostal.com in prod"
移动引用(原地重新标记)
portunus retag vercel-mdostal --env staging
portunus retag vercel-mdostal --tags team=platform
如果更改会与另一个现有引用的标签组合冲突,则会拒绝该更改 — 失败即关闭,与 resolve_by_tags 的约定相同。
更丰富的元数据 — 密钥是什么、用途是什么、如何注入,以及与其他密钥的关系
引用包含 description(是什么)、purpose(用途)、injected_as
({env_name: "env:VAR" | "file:path"},记录它在各环境中的注入方式)、
group(将其放入树状结构中的层级路径,例如 project-y/supabase/auth),以及
related(明确引用其他引用名称)——这些字段均为可选,且都是对现有标签架构的补充:
portunus reg add stripe-prod dostal-stripe-live \
--scope shared --kind stripe --org firefly-events --project mdostal.com --env prod \
--group mdostal.com/stripe --related mdostal-com-mongodb-prod
# description/purpose/injected_as/group/related are also settable via retag or the UI's edit form
org 位于 project 的上一层 — 作为跨多个项目的组织级统括项(例如,涵盖
demo-cicd、shindig 等项目的 firefly-events),也是 Standalone UI 的 Vault Map
下钻视图和 Console 筛选器所依据的结构化字段。与其他结构化标签一样,
缺少 org 绝不是错误 — 它会归入 (no org set) 分组,与 group 已有的
(ungrouped) 分组一样,不会因此被丢弃。
portunus tree — 按层级和关系浏览密钥
面向 LLM 的结构查询:将每个引用的 group 渲染为真正的树状结构,并在每个叶节点
显示 related 链接。没有 group 的引用不会消失 — 它会显示在 (ungrouped) 分组下,
而不是被静默丢弃:
$ portunus tree --project demo-cicd
demo-cicd/
clerk-webhook/
demo-cicd-clerk-webhook-secret-dev
demo-cicd-clerk-webhook-secret-prod
event-api/
dev/
demo-cicd-event-api-dev-mongo-uri
demo-cicd-event-api-dev-jwt-secret
... (48 more)
prod/
demo-cicd-event-api-prod-mongo-uri
... (39 more)
... (18 more apps)
--json 会以嵌套 JSON 的形式提供相同结构,供程序/UI 使用。若 related 条目指向当前
结果集之外的引用,则会标记为 (unresolved),不会被静默丢弃。Project Explorer UI
标签页会在客户端使用相同数据渲染完全相同的树。
portunus impact — 轮换实际会影响什么
tree 展示结构。impact 针对单个引用回答“如果轮换这个密钥,会有什么出问题?”它会
双向传递遍历 related,列出遍历到的每个引用,以及它们关联的项目、仓库、source_files、
环境和 injected_as 目标:
$ portunus impact supabase-service-key
rotating supabase-service-key touches 3 other references in 3 projects (edge-fns, project-y, worker)
supabase-service-key
project=project-y env=prod repo=project-y-api group=project-y/supabase/auth state=enabled
injected_as prod=env:SUPABASE_SERVICE_KEY
file project-y-api:docker-compose.prod.yml
supabase-jwt-secret (related)
edge-fn-key (related)
file edge-fns:.env.production
worker-supabase-key (lists supabase-service-key as related)
injected_as prod=file:/run/secrets/supabase
...
suggested related -- NOT linked, not walked above; ...
supabase-anon-key [shared source file: docker-compose.prod.yml; shared group parent: project-y/supabase; same repo: project-y-api]
遍历只会沿着人工维护的 related 列表进行。建议的相关项区块会在读取时,根据共享
源文件、组、上级组或仓库得出。它不会被写入、遍历或计数,因此 related 始终是由人
选择的列表。rotation run 会在运行前打印相同的摘要。当实际轮换涉及其他引用时,
系统会请求确认(若没有终端,则需要使用 --yes)。UI 详情抽屉会在 Rotate 按钮上方显示
相同的树。
repo/source_files — 哪个 git 仓库(以及文件)实际会使用某个密钥
查看实际数据后发现了一个真正的缺口:group 已经大致记录了服务/环境层级
(demo-cicd/event-api/prod),但没有字段区分使用密钥的 git 仓库 和密钥所在的
云项目 — 一个共享的 GCP 项目(demo-cicd)可以涵盖许多仓库/服务。repo 是一个新的
结构化字段(find --tags repo=event-api 可立即使用,与 provider/project/env 相同);
source_files 是该仓库中引用密钥的文件路径列表(例如 docker-compose.yml、CI 工作流)
— 与 related 一样,是可选且由人填写的字段,不会自动发现:
portunus reg add stripe-prod dostal-stripe-live --repo billing-service
portunus retag stripe-prod --source-files docker-compose.prod.yml,.github/workflows/deploy.yml
portunus find --tags repo=billing-service
一次性为多个引用补录数据 — 这才是为数百个已有分组的密钥解锁批量操作的关键,无需
逐个引用执行 retag:
# Preview first -- makes zero writes, reports exactly what would change:
portunus retag-bulk --group-prefix demo-cicd/event-api --repo event-api --dry-run
# Then actually apply it:
portunus retag-bulk --group-prefix demo-cicd/event-api --repo event-api
选中所有 group 以 --group-prefix 开头的引用(纯字符串前缀,不是查询语言),并逐个
重新打标签 — 若某个引用发生冲突失败,会在 failed 下报告,但绝不会中止批次的其余
操作,这与 drop-bulk 已确立的做法一致。
portunus tree --by repo 会按 repo 而非 group 对相同的树重新渲染 —
同一命令、相同结构,在相同底层数据上提供第二种维度:
portunus tree --project demo-cicd --by repo # --by group is the default, unchanged
没有设置 repo 的引用会显示在 (no repo set) 分组下,与 (ungrouped) 对 group 的
处理一样,不会丢弃任何引用。Project Explorer UI 标签页会在树状视图上方提供对应的
Group/Repo 切换按钮。
“此项目中有哪些密钥?”——面向 LLM 的元数据查询
Agent 无需看到任何值,就能查询有哪些内容可用——仅返回元数据,结果数量可以为零或多个,不要求失败关闭且只能匹配一项(这是浏览,而非解析):
portunus list --project mdostal.com
portunus ask "what secrets are available for mdostal.com"
portunus search——不知道确切名称时查找密钥
portunus list 会列出项目的所有引用。portunus search 则会查找你真正需要的内容——在一个或多个项目中,按名称、sm_name、描述、用途、标签和分组进行自由文本子串匹配:
# Find anything mentioning "linear" across all projects
portunus search "linear"
# Scope to one project
portunus search "discord" --project ffe-cicd
# Machine-readable output (what the MCP tool and UI route use)
portunus search "stripe" --json
# Narrow by state or env
portunus search "api" --state enabled --env prod
结果排序方式:先列出 enabled 引用,再按字母顺序排列。查询为空时始终不返回任何结果——此命令绝不会列出整个注册表。其结构与 portunus list 一样,有三个入口:一个核心实现(search.py),并接入 CLI、MCP 工具(portunus_search)和 UI API 路由(/api/search?query=<text>)。
推荐 Agent 使用 MCP 工具——不知道确切引用名称时,先调用它,再调用 portunus_resolve_*:
# Agent: find the Linear token, then inject it
refs = portunus_search("linear", project="ffe-cicd")
# refs[0]["name"] → "linear-token"
GCP:多项目 + 无密钥认证(WIF)+ 发现
GcloudBackend 默认采用无密钥认证——不需要静态服务账号 JSON,也不需要长期有效的 AWS 风格密钥对(assert_no_long_lived_cloud_keys() 会强制执行这一点)。每个项目的绑定信息存储在 PORTUNUS_HOME/vault-bindings.json(权限为 0600,记录项目 → 后端/同步模式/WIF 受众/账号——参见下文“按项目/引用进行密钥库路由”);如果没有绑定文件,则 PORTUNUS_GCP_PROJECT/PORTUNUS_GCP_WIF_AUDIENCE 会保持现有的零配置单项目行为不变。同一进程中的两个引用可以指向不同的 GCP 项目,并分别根据各自的绑定进行解析。
portunus auth gcp --project my-project-12345 # mint + report identity/scope/expiry only
同时使用多个 GCP 账号。 gcloud 可以同时存储多个已认证账号(gcloud auth login <email> 会添加账号,不会移除其他账号),但任何未显式指定身份的命令都会使用 gcloud 认定的“活动”账号——这是一个可变的单一指针。vault-bindings.json 的 account 字段解决了这个问题:按项目设置该字段后,Portunus 对该项目的每次 GCP 调用都会显式传入 --account=<email>,不受 gcloud 当前活动账号的影响。(同一项目不能同时绑定 WIF——已签发的访问令牌已包含身份。)添加 --impersonate-service-account,即可使用最小权限服务账号执行操作。使用 WIF 时,会在进程内通过 IAM Credentials API 执行身份模拟,因此完全无需登录 gcloud。参见 docs/gcp-keyless.md。
portunus bindings set demo-cicd --account [email protected]
portunus bindings set my-project-12345 --account [email protected]
portunus bindings show # real values -- a local CLI reading your own 0600 config
portunus discover --provider gcp --project demo-cicd # uses [email protected]
portunus discover --provider gcp --project my-project-12345 # uses [email protected]
# both work in the same session, regardless of which account gcloud currently considers active
发现功能为只读且需主动启用——它会枚举实时 GCP Secret Manager 项目中已有的内容(名称 + 标签 + 创建时间,绝不读取值——discover.py 完全不引用任何后端的 access() 方法),让你注册真实密钥,而不是盲目重新创建。以下是针对真实项目的操作示例:
$ portunus discover --provider gcp --project my-project-12345
not-registered AUTH_SECRET
not-registered SANITY_API_ADMIN_TOKEN labels={'project': 'dafshiq1', 'scope': 'admin', 'service': 'sanity', ...}
not-registered dostal-shared-gemini labels={'app': 'dostal-swarm', 'kind': 'gemini', 'scope': 'shared'}
... (19 secrets total)
$ portunus discover --provider gcp --project my-project-12345 --register
registered my-project-12345-auth_secret (state=requested)
registered my-project-12345-sanity_api_admin_token (state=requested)
...
所有通过 --register 注册的引用都会进入 state=requested 状态——这与 Agent 发起的 ask "add ..." 请求所使用的失败关闭占位状态相同,因此在人工审核并提升状态之前,任何内容都不会变为可注入状态。本地引用名称由 <project>-<sm-name> 生成,因此即使两个不同项目恰好使用相同的密钥名称,也不会发生冲突。
对于绑定了 --sync-mode cached 的项目(见下文),--register 还会立即预热本地加密缓存——无需先单独进行首次解析往返,即可离线使用该值。引用的 state 仍保持 requested:只是更早完成缓存,不会因此更早变为可注入状态。
按项目/引用路由保管库 + 同步到本地缓存
引用会根据其所属项目(或引用本身)实际绑定的后端进行解析——而不是整个进程共用一个全局 PORTUNUS_BACKEND 选择。优先级分为三个层级:引用自身的 backend 覆盖项(通过 portunus_drop/reg add/retag --backend {local,gcp,aws} 设置)优先级最高;其次是项目的 VaultBinding.backend(portunus bindings set <project> --backend ...);最后才是当前的全局 PORTUNUS_BACKEND 环境变量,保持不变,作为最终回退选项。这意味着 my-project-12345 和 demo-cicd 可以在同一个进程中正确解析,无需手动设置 PORTUNUS_BACKEND=gcloud。
portunus bindings set gig-tracker --backend local # everything in this project stays local
portunus bindings set my-project-12345 --backend gcp --sync-mode cached
portunus bindings show # backend + sync_mode per project
--sync-mode cached 可让项目启用感知最近更新、仅拉取的同步到本地缓存——只从 GCP 同步到本地,绝不反向同步。每次访问时,Portunus 都会检查远程当前值的版本是否与本地缓存一致(通过轻量的纯元数据 gcloud secrets versions describe 调用);如果一致,就直接从本地加密缓存提供数据,不会进行远程抓取;如果不一致(发生了实际轮换,或是首次同步),则会重新抓取并缓存。portunus sync <project> 可显式强制执行此检查——适用于部署时一次性获取一组最新密钥,而不是让每个实例针对每个密钥都实时往返 Secret Manager:
$ portunus sync my-project-12345
synced my-project-12345-resend_audience_id
already-fresh my-project-12345-google_generative_ai_api_key
配置保存在 PORTUNUS_HOME/vault-bindings.json(0600)中——这是早期 gcp-bindings.json 的后续版本,并采用兼容迁移的回退读取方式:如果只存在旧版文件,则每个条目都会以 backend="gcp", sync_mode="direct" 加载,与当前实际行为逐字节一致,无需手动迁移。
account(本地 gcloud 身份,例如用于多账户设置)和 wif_audience(WIF 提供商资源名称)也可以直接在 Project Explorer 的绑定面板中编辑——Standalone UI 不再只能通过 CLI 调用来设置这些值。这两个字段都不是凭证(account 是身份选择器;身份本身必须已通过 gcloud auth login 在本地完成身份验证),因此 UI 中都不会进行遮蔽。
轮换绑定的 account(portunus rotation-bindings set <provider> --account ... 已支持的自由文本上下文提示,例如 Vercel 团队标识)同样可以在引用的详细信息视图中直接编辑,就在 Auto-rotate 按钮旁边。status(stub/real)由代码控制,UI 中始终无法设置——目前只有 OAuth、GCP service-account、Resend、Grafana Cloud、YouTrack、Vercel 和 GitHub(OAuth App token)适配器是真实实现(Stripe 仍是存根);允许用户将存根切换为真实实现的 UI 控件,会让人误以为点击后实际发生的事情并非如此。
一次批量导入多个密钥——例如,在通过 portunus_resolve_exec 逐个尝试候选密码/密钥之前先批量导入,而在查看结果之前不会暴露哪个候选项有效:
portunus drop-bulk entries.json # [{"name": ..., "sm_name": ..., "value": ...}, ...]
格式错误的条目会列在单独的 failed 列表中,不会导致批次中的其余条目中止。
Vault 备份:由密码短语加密的导出/导入
portunus vault export/portunus vault import 会将整个 Vault 关键状态(引用注册表、主密钥、加密值、Vault 绑定、审计日志)打包为一个可移植归档 — 适用于更换机器、重新安装,或创建真正的备份,且不依赖完全签名的桌面应用自身的本地沙箱机制。
export PORTUNUS_EXPORT_PASSPHRASE="something only you know" # or answer the interactive prompt
portunus vault export --out ~/backups/portunus-$(date +%F).pvault
portunus vault import ~/backups/portunus-2026-08-14.pvault --force # on a new machine
归档会使用你的密码短语重新加密(PBKDF2-SHA256,600k 次迭代)——其中绝不会包含 Vault 自身正在使用的解密密钥。导入时若密码短语错误,会安全失败并给出明确错误;密码短语绝不能通过内联标志传入,只能通过 PORTUNUS_EXPORT_PASSPHRASE 环境变量或交互式提示输入。如果 PORTUNUS_HOME 中已有 Vault 状态,import 会拒绝操作,除非你传入 --force(这会完全替换,而非合并)。此功能设计为仅供 CLI 使用——没有 MCP 工具或 UI 界面:包含 Vault 中所有密钥的归档,绝不应由面向 LLM 的工具触发,必须由你亲自运行命令。
Vault 访问权限转移:有范围限制、纯 JSON、不含值(vault access)
portunus vault access export/import/verify 可在两个 Portunus 实例之间共享 Vault 的部分或全部可用访问权限——这不同于上文的 vault export/import,后者会转移整个 Vault,包括所有值。vault access 捆绑包仅包含注册表指针和绑定:从结构上说,它不可能包含密钥值,因此是纯 JSON,绝不会由密码短语加密。
portunus vault access export --project my-gcp-project --out access.json
portunus --home /path/to/second/vault vault access import access.json
portunus --home /path/to/second/vault vault access verify
export 接受 --project/--org/--tags k=v,... 筛选条件(采用 AND 语义;不指定筛选条件则导出全部内容),并为每个引用添加 resolved_backend 字段 — 该字段只在导出时计算一次,方式与其他命令解析引用实际使用的后端相同(显式覆盖 → 项目绑定 → 导出实例自身的全局默认值)。import 会读取该字段,而不是重新推导,因为只有导出实例知道自己的全局默认值。导入时,resolved_backend 为 "local" 的引用一律设为 state=requested,无论它在源端的状态如何——值只存在于源机器上,其他做法都会对就绪状态造成误导;需由人工通过 portunus drop 补全。其他后端(GCP/AWS/等)的指针则原样转移——无需转移任何值,因为值保存在云服务提供商处,而非本地。若名称冲突(同一引用,但 sm_name/后端不同),则拒绝导入该条目并明确报告,但不会中止批次中的其余操作;传入 --force 可覆盖。
verify 会通过真实的逐引用可达性检查来验证访问,并使用真实 resolve/ask/mcp 调用所用的同一个边界安全 resolve_call() ——解析出的值会传递到一个立即将其丢弃的边界,绝不会打印或返回。失败时会给出可执行的提示:对于 state=requested 的引用,会报告准确的 portunus drop 命令;对于真实的身份验证/IAM 错误,则会报告 portunus auth login/gcloud … 命令,并注明该绑定自身的账户和项目。verify 仅供 CLI 使用(没有 MCP 工具)——一次调用可能会对注册表中的每个引用触发真实的后端 API 调用,因此这是由人工发起的批量操作,与 vault export/import 的安全原则一致。
注意:对全新的 PORTUNUS_HOME 运行任何命令——包括在临时目标上执行 vault access import/verify——都会生成一个未使用的 master.key。这是本地后端构造方式带来的既有副作用(所有命令长期以来都有此行为),并非 vault access 新增的行为。它只是一个闲置的 Fernet 密钥,从未用于任何数据;实际用于保存加密值的文件 vault.enc.json,不会仅因运行 vault access 而创建。
完成待处理请求(portunus ui open)
portunus ask "add ..."(见下文)会创建一个没有值、state=requested 的占位项——Agent 已记录它所知的所有元数据字段(org/provider/project/env/tags),但仍需由人工提供实际值。portunus ui open [--fulfill NAME] 是 Agent 自己的工具调用能够运行、以便简化这一流程的唯一命令:它只会打开一个浏览器标签页,然后立即返回(无需 TTY,也不会等待人工输入),并直接带用户进入现有独立 UI 的“Fulfill”表单,该表单已根据该引用自身的元数据预先填好——用户只需输入值:
portunus ui open # just opens the dashboard
portunus ui open --fulfill vercel-mdostal # deep-links straight into that pending request's form
读取 PORTUNUS_UI_URL(默认值为 http://localhost:3000,与下文 npm run dev 自身的默认值一致)。如果 --fulfill NAME 不存在或不再处于 state=requested,或者该 URL 完全无法访问 UI,命令会清楚地拒绝执行——且绝不会先打开浏览器(不会打开失效页面)。也可以手动使用同一个“Fulfill…”操作:在 Console/Project Explorer 中点击任意处于 requested 状态的引用,然后在其详情抽屉中点击“Fulfill…”。
指定其他保险库(--home)
portunus --home /path/to/other-repo/.portunus reg show
显式指定单次调用的保险库覆盖路径——不会在各个仓库中自动搜索多个保险库(这仍不在支持范围内)。省略该选项时,仍按之前的方式解析 PORTUNUS_HOME/DOSTAL_SECRETS_HOME/~/.portunus。
在边界处解析
Exec 模式——明文只存在于子进程的 argv 中,不会写入磁盘:
portunus resolve --exec curl -H "Authorization: Bearer {{secret:shared-anthropic}}" https://api.anthropic.com/v1/messages
临时文件模式——写入一个 0600 文件并打印其路径(绝不打印值);调用方负责读取并删除:
path=$(portunus resolve "key={{secret:shared-openai}}")
# ... use "$path" ...
rm -f "$path"
策略:拦截/审批/授权
portunus gate shared-anthropic # now requires human approval to resolve
portunus approve shared-anthropic --ttl 3
portunus grant shared-anthropic serviceAccount:[email protected] # audit record only, no IAM change
portunus state shared-anthropic revoked # emergency: blocks all injection
portunus status shared-anthropic
审计
portunus audit 25 # last 25 access decisions (names + results, never values)
portunus verify # prove the hash chain is intact
MCP 服务——供其他 Agent/运行框架使用
portunus mcp 会启动一个 stdio MCP 服务——这是 OSTIARIUS 的第三个入口点(另外两个是此 CLI 和独立 UI),让任何支持 MCP 的 Agent 或运行框架都能直接查询并注入 Portunus 密钥,而不只是当前这个 Agent,无需向人工索要密钥。使用同一个包、同一个进程,没有子进程边界——该服务会在进程内调用 Registry/Resolver/Broker,与 cli.py 的方式相同。
portunus agent init # registers it (+ Codex CLI's mcp add, + the usage skills below)
# or by hand, same effect for Claude Code specifically:
claude mcp add --scope user portunus -- portunus mcp
portunus agent init 会检测机器上实际安装了 Claude Code 和/或 Codex CLI,并为每个已安装项完成配置——操作具备幂等性,因此安装新运行框架后重新运行始终安全。portunus agent status 会报告当前已注册的项目,不会更改任何内容;--harness claude/--harness codex(可重复指定)可将 init 限定为只配置其中一个。
使用技能,而不只是工具。 portunus agent init 还会将四个 Claude Code 技能(portunus-ask、portunus-drop、portunus-vault-setup、portunus-vault-audit)安装到 ~/.claude/skills/——这些技能与本仓库的 .claude/skills/ 中附带、用于在此代码库中工作的技能相同,现在可供机器上任何项目中的任何 Claude Code 会话使用。它们包含实际使用约定(何时使用 ask 而非 drop、坚持失败时关闭而不猜测、成功存储后绝不回显值),而下方原始 MCP 工具表中没有这些内容——新 Agent 获得的不只是工具名称,还有判断能力。
工具:
| 工具 | 返回内容 |
|---|---|
portunus_health(shallow=False) | 只读深度自检,与 portunus health --json 相同;shallow=True 仅检查存活状态 |
portunus_list(project) | 项目中每个引用的元数据——绝不返回值 |
portunus_tree(project="") | 分组层级 + 相关链接,结构与 portunus tree --json 相同 |
portunus_impact(name, max_depth=None, suggest=True) | 某个引用的影响范围(轮换会影响哪些内容),结构与 portunus impact --json 相同——只读,绝不返回值 |
portunus_ask_preview(request) | 明确的取值请求会解析到什么内容——仅元数据和预览,绝不注入 |
portunus_bindings_show(project="") | 配置的各项目保险库绑定(后端/同步模式/账户/WIF 受众) |
portunus_discover(project, register=False) | 与实时 GCP Secret Manager 项目的只读差异对比;register=True 会将尚未注册的密钥写入为 state=requested |
portunus_suggest_metadata(name, description="", purpose="", group="", tags=None) | 提议 description/purpose/group/tags——写入待处理的 sidecar,绝不写入实时字段;由人工通过 portunus metadata confirm 或 UI 确认 |
portunus_crawl_candidates(org="", project="") | 为所有缺少元数据的引用生成发现信息包(sm_name/group/project/org/repo/vault-binding/rotation-binding)——绝不返回值,也绝不写入;读取后,对确实有提议的字段调用 portunus_suggest_metadata |
portunus_leak_status(name="") | 一个或所有存在活动发现项的引用的已计算泄漏严重程度/发现项数量/时间戳——绝不返回值,绝不返回文件内容,也不会触发扫描 |
portunus_run_leak_scan() | 对人工配置的泄漏扫描路径执行真实扫描,并持久化新发现项——每个发现项包含 {ref_name, path, line_number},绝不返回值;只能读取已通过 portunus_leak_scan_config_add_path 添加的路径 |
portunus_leak_scan_config_show() / _add_path(glob) / _remove_path(glob) | 管理泄漏扫描路径配置——仅支持 glob,绝不返回值,也不会自行扫描任何内容 |
portunus_leak_mark_rotated(name) | 清除某个引用的活动泄漏发现项——这是人工/Agent 作出的声明,Portunus 无法独立验证;还会使受影响文件的扫描水位标记失效,因此即使过早调用,下一次扫描仍会重新标记 |
portunus_resolve_to_tempfile(name="", tags=None) | 保存解析后值的 0600 临时文件路径——绝不返回值本身 |
portunus_resolve_exec(argv, name="", tags=None) | 使用替换后的 {{secret}} 标记运行 argv 所得的 {stdout, stderr, returncode}——绝不返回解析后的命令行 |
portunus_drop(name, sm_name, value, ..., backend="") | 创建新的仅限本地保险库密钥——返回 {name, sm_name, state},绝不返回值 |
portunus_drop_bulk(entries) | 一次调用创建多个本地保险库密钥——返回 {"created": [names], "failed": [{"name","error"}]},绝不返回值 |
portunus_state(name, state) | 更改引用的生命周期状态——返回 {name, state} |
portunus_sync(project) | 强制检查项目中每个缓存模式引用的新鲜度——返回 {"synced", "already_fresh", "failed"},仅包含名称 |
portunus_rotation_status(provider="") | 配置的各提供商轮换绑定(状态/账户/能力)——可查询单个或全部提供商。capability 来自适配器注册表:oauth、gcp、resend、grafana-cloud、youtrack、vercel 和 github 为 auto(GitHub 按令牌类型细分的情况见 docs/rotation.md),sentry 为 manual,Stripe 存根为 unknown |
注入工具使用与 CLI 自身的 inject/ask 相同的双重寻址方式:提供准确的 name(来自此前的 portunus_list/portunus_tree 调用)或 tags——切勿使用原始的 {{secret:NAME}} 占位符语法。portunus_resolve_exec 是“替我执行调用”的工具:在值应放置的位置写入字面量 {{secret}} 标记,Portunus 会构造真实占位符,并通过会捕获输出的 subprocess.run(超时设为 30 秒)运行命令,而不是使用 CLI 默认的 execvp。非零退出码会正常返回,不会被视为工具级错误——这是封装命令自身的语义,并非 Portunus 的错误。 |
portunus_drop 是创建端的对应工具,可让已移交的 Agent 实例端到端地为新项目设置密钥,而无需调用 CLI——但仅限本地保险库:如果当前后端是 gcloud/aws,它会安全失败,并返回与 CLI 自身的 drop 相同的消息(Portunus 尚无写入 GCP Secret Manager 或 AWS 的路径——创建真正的云端密钥是另一项尚未实现的功能)。它会将新引用设为 state=dropped(安全失败,与 CLI 一致);portunus_state(name, "enabled") 是单独的显式步骤,用于使其可注入。value 是此服务端所有工具中唯一一个参数,其数据流向是从调用 Agent 的自身上下文流入,而不是从 Portunus 流出——这是接收新密钥并存储这一操作的固有属性,并非越界行为。Portunus 在此方面的保证范围较窄,但仍然是绝对的:该值绝不会出现在 portunus_drop 自身的返回结果中,不会被记录,也不会进入审计链——但调用 Agent 有责任避免之后再次向用户复述该值,就像它有责任不把 resolve_to_tempfile 路径读回到自身输出中一样。
操作示例——“把个人 Gemini 密钥给他们”,但绝不交出密钥:
portunus_resolve_exec(
argv=["curl", "-s", "https://generativelanguage.googleapis.com/v1beta/models?key={{secret}}"],
name="my-project-12345-google_generative_ai_api_key",
)
# -> {"stdout": "{...real model list JSON...}", "stderr": "", "returncode": 0}
# the key itself never appears in the tool's return value, this call's own output, or any log.
通过 Portunus 管理身份验证生命周期
portunus auth login [email protected] # thin wrapper around `gcloud auth login` -- the one command to remember
portunus auth status [--json] [--strict] # per binding: wif ready / account ok|missing|EXPIRED; --strict exits 1
这是刻意设置的有限功能——不会自动重新进行身份验证。login 仍会打开真实浏览器(Portunus 不会省略这一步);status 会按项目报告每个已配置绑定的身份验证状态:已通过验证、缺失或已过期。它会通过签发真实令牌来进行检查,因此,列出的账号若卡在重新验证页面,会显示为 EXPIRED。这样,过期凭据就能在注入或写入因其失败之前显现出来。
这两个命令都不会接触任何密钥值——只会读取账号邮箱和 gcloud 自身的凭据元数据。
OAuth 令牌代理:存储刷新令牌,按需签发访问令牌
portunus oauth store/list/remove + --backend oauth 让 Portunus 可以保存提供方签发的 OAuth 刷新令牌,并按需通过它签发短期访问令牌,仅在边界处注入——遵循其他所有密钥一贯的安全模式。
这不是什么:Portunus 不会自行运行任何 OAuth 授权同意或浏览器重定向流程,也不会捕获浏览器会话 Cookie。一次性的授权同意步骤——即最初获取刷新令牌——完全在 Portunus 之外进行,通过任何已能合法覆盖所需权限范围的机制完成(例如,针对 GCP 相关访问使用 gcloud auth application-default login --scopes=...——这遵循了 portunus auth login 已经为 gcloud auth login 确立的委托先例,只是指向可移植凭据文件,而不是 gcloud 自己的内部文件)。Portunus 会在你向它提供由此得到的凭据包后开始工作。
# Bootstrap (outside Portunus) -- writes ~/.config/gcloud/application_default_credentials.json
gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform
# Hand Portunus the resulting bundle (never inline -- stdin/--value-file only)
python3 -c "import json; d=json.load(open('$HOME/.config/gcloud/application_default_credentials.json')); \
print(json.dumps({'client_id': d['client_id'], 'client_secret': d['client_secret'], \
'refresh_token': d['refresh_token'], 'token_endpoint': 'https://oauth2.googleapis.com/token'}))" \
| portunus oauth store google personal --stdin
# Point a reference at it (sm_name encodes "<provider>:<account>")
portunus drop my-gmail-token "google:personal" --backend oauth --stdin <<< "unused-placeholder"
portunus state my-gmail-token enabled
portunus resolve "Authorization: Bearer {{secret:my-gmail-token}}" # a real, live, short-lived access token
或者完全跳过这一行命令:Standalone UI 的“+ Add OAuth credential”按钮会打开一个包含丰富元数据的录入表单(名称/提供方/账户/组织/项目/环境/标签/描述/用途/注入方式/分组/关联项/仓库/源文件——与 AddSecretForm 为常规密钥提供的字段集相同),并明确提供两种凭据来源供选择:从检测到的本地 gcloud ADC 文件自动填充(由 Next.js 服务端直接读取;client_id/client_secret/refresh_token 的值绝不会发送到浏览器,也不会在浏览器中显示——表单只显示“已检测:是/否”,绝不显示这些值);或手动输入(client_secret/refresh_token 字段会遮蔽内容,与此 UI 中所有其他密钥值一样遵循 type="password" 的要求)。一次提交即可同时存储凭据(portunus oauth store)并注册指向该凭据的引用(portunus drop --backend oauth)——与手动分两步执行没有行为上的差异。
在同一个保险库下使用多个账户是首要支持的场景,而非事后补充:oauth store 按 (provider, account) 命名空间隔离,因此 google:personal 和 google:firefly-events(或任何其他标签)可以作为完全独立的凭据共存——分别获取令牌、分别解析、分别执行 oauth remove。这与 org/project 作用域为 Portunus 中的其他引用提供的“单个 Portunus 下包含多个子保险库/账户”的模式相同。已使用两个真实且不同的 Google 账户进行实时验证:两者获取到的访问令牌确实不同,并通过独立的 SHA-256 摘要确认(绝不比较原始值)——详见 docs/architecture.md §20 和 .pHive/epics/portunus-oauth-token-broker/docs/lessons-learned.md。
gcloud auth login 不等于 gcloud auth application-default login——两者很容易混淆,但对应不同的凭据存储。auth login 用于对 gcloud CLI 本身进行身份验证(其内部 SQLite 存储已由 portunus auth login 封装);auth application-default login 则会写入可移植的 JSON 文件(~/.config/gcloud/application_default_credentials.json),上述引导流程实际读取的就是此文件。运行错误的命令不会报错——它只会悄无声息地验证错误的对象(已确认 ADC 文件本身的修改时间保持不变)。
验证引导后的凭据实际属于哪个账户:gcloud 自身的成功提示不会显示你在浏览器中选择的账户——只会显示一个无关的配额项目(如果有)。如需确切确认(而不是根据上下文猜测),请获取令牌并直接向 Google 查询:
portunus resolve --exec curl -sG "https://oauth2.googleapis.com/tokeninfo" \
--data-urlencode "access_token={{secret:my-gmail-token}}" | python3 -c "import json,sys; print(json.load(sys.stdin)['email'])"
(使用 resolve --exec,而非 $(...) 替换——令牌绝不会出现在 argv/ps 输出中,遵循与本 README 其他示例相同的边界安全规范。)
访问令牌会缓存在内存中(仅限此进程,绝不写入磁盘),直到接近其有效期,然后自动重新获取——在 refresh 令牌本身过期或被撤销之前,无需重新引导(这完全由提供方决定,与 Portunus 无关)。获取令牌失败(refresh 令牌过期/被撤销)时,会返回简洁的 BackendError,与其他后端的身份验证失败相同——portunus vault access verify(portunus-vault-transfer)已会将其转换为可执行的提示,指向引导命令。
故障排查:如果在 macOS 上获取令牌时遇到 SSL 证书错误,几乎总是 python.org 官方安装程序的问题(其捆绑的 Python 不会自动使用系统 CA 信任存储)——请运行对应 Python 版本自带的 Install Certificates.command,或将 SSL_CERT_FILE 设置为 python3 -c "import certifi; print(certifi.where())" 的输出。这不是 Portunus 的问题;受影响的安装环境中,任何 Python HTTPS 客户端都适用相同的解决方法。
Standalone UI
ui/ 下仅限 localhost 访问的 Next.js 应用 — Console(默认标签页,带分面筛选的表格 + 详情抽屉)、Vault Map(第二个标签页,按组织 → 项目逐级深入 — 选择一张组织卡片,再选择一张项目卡片,视图便会限定为该范围,每个层级都会显示自己的引用计数和完整度摘要;这是真正解决 vault 跨越 30 多个仓库后卡片墙变得难以管理的问题的方案)、Project Explorer(第三个标签页 — 按 GCP 项目划分的视图:已注册的内容、可通过 portunus discover 发现的内容(支持一键“全部注册”),以及该项目是否配置了 WIF 绑定)、Settings(组织/项目层级概览 + 角色/策略管理 — 参见下文“Roles & permissions”)、About(应用内文档,介绍以上所有内容),以及 Ask Bar(在全部四个主标签页中持续显示的侧边栏,由 portunus ask 提供支持)。首次运行设置向导会在你首次使用全新的 PORTUNUS_HOME 打开 UI 时自动显示(配置任何内容后便不会再显示)— 引导你了解 Portunus 的三个组成部分、选择首个 vault 的后端(如果选择 GCP,还会在 UI 内提供真正的 gcloud auth login 触发入口),以及执行首次 discover。TypeScript 中不重复实现任何门控逻辑:ui/app/api/ 下的每个 API 路由都会调用 CLI 使用的同一个 portunus 控制台脚本,因此 Broker.check_injectable 始终是唯一的门控实现。添加密钥表单是唯一有意设置的人工明文输入入口(与 portunus drop --stdin 一致)— 值只通过 stdin 传给 CLI,绝不会作为 argv 元素,也绝不会记入日志。密钥的 org/provider/project/env/repo/source_files/description/purpose/injected_as 元数据可在详情抽屉的 Move 表单中查看和编辑 — 绝不显示值,且始终通过同一条 portunus retag 路径操作。state=requested 的引用(Agent 自己执行 portunus ask "add ..." 创建的请求)会在其详情抽屉中显示“Fulfill…”操作,打开同一个添加密钥表单,并预先填入 Agent 收集到的所有信息 — ?fulfill=<name> 深层链接和可直接打开该页面的 portunus ui open --fulfill 参见上文“Fulfilling a pending request”。
缺失元数据提示。 缺少 description/purpose/org/project/tags 的引用,无论在哪里呈现,都会显示“⚠ missing metadata”徽章;Console 中还会提供可点击的 Metadata 分面筛选器,精准筛出仍需补充信息的内容 — 对真实 vault 的实时检查发现,该项目几乎每条引用都有此问题,因此这是一个确实存在、并非理论上的待解决缺口。
自定义视图。 为适合任务的引用集合命名并由人工整理,使其可以按无法映射到 org/project/env 的维度分组 — “Shindig 部署所需的全部内容”,无论这些引用实际存放在哪里,都可以汇集在一起。在 Console 的“My views”面板中创建视图;也可直接在引用自己的详情抽屉中将其添加到或移出任意视图。CLI:portunus views create/add/ remove/delete/show。
LLM 提议,人工确认。 Agent 可以通过 portunus_suggest_metadata MCP 工具为引用提议 description/purpose/tags/group — 提议会进入待处理的 sidecar,绝不会写入实时字段。DetailDrawer 会为每个待处理字段显示“由 <agent> 提议:'...' [Confirm]
[Reject]”;Confirm 会通过与手动编辑完全相同的 portunus retag 路径应用提议,Reject 则会丢弃提议;除非你明确点击确认,否则两者都不会触及实时字段。Agent 永远不能提议路由字段(org/provider/project/env/repo/backend)— 这些字段会影响 resolve 实际使用的后端。CLI:
portunus metadata confirm/reject/pending。
角色与权限——真正可选择启用的访问控制。 设置中可以记录 {scope: org/project/env/repo, role, actions, principal} 策略记录(portunus roles set/delete/ show、PORTUNUS_HOME/roles.json),将策略限定到特定 Agent 身份(principal——留空或设为 * 表示所有人,并与每条既有记录向后兼容)。每次 resolve 都会生成一条 would-allow/would-deny 审计记录,反映策略存储中的判定,无论是否启用强制执行。portunus roles enforce on 是显式选择启用的开关,会让 would-deny 判定实际触发错误(NotAuthorized),而不只是记入日志——默认关闭;即使开启,没有配置任何策略的范围也始终完全开放(为一个项目/repo 配置首条策略,不会在毫无提示的情况下锁定其他所有引用)。目前尚未按范围控制:任何调用者仍可通过 list/tree 获取整个 vault 的元数据;approve() 的审批令牌也只限定到引用名称,而不关联请求者身份——这两项都是明确列出并特意推迟处理的实际缺口(见上文 Roadmap)。
元数据爬取与部署文档报告。 设置中的“Crawl & report”部分会列出仍缺少描述/用途/组织信息的引用,并提供“Fetch crawl bundle”按钮——返回的内容与 portunus crawl --json 相同(sm_name、group、project、org、repo、其项目的 vault 绑定及其 provider 的轮换绑定),并明确将其定位为供 LLM 会话读取的上下文,而非自动填充工具;此功能不会调用 LLM,也不会自行写入元数据。“View report”会在应用内呈现 portunus report 的 Markdown 输出(使用小型自定义渲染器,不依赖 Markdown 解析库——输出内容受控且范围有限);“Download report”仍会将其保存为文件。报告采用组织 → 项目结构,并包含明确的缺失项部分,可立即用作真正的“部署文档”起点,无论是否已确认任何来自爬取的元数据。CLI:portunus crawl [--org] [--project] [--json]、portunus report [--org] [--project] [--out path]。
泄漏检测——侦查,而非预防。 设置中的“Leak scan”部分(以及 portunus leak-scan)会检查受管理密钥的实际值是否出现在不该出现的地方——日志、.claude 对话记录、shell 历史记录,或你明确配置的任何其他路径(portunus leak-scan config add-path <glob>——添加路径前不会扫描任何内容;没有默认路径)。发现匹配项后,严重级别会随时间升级(warn → urgent → critical),直到你运行 portunus leak mark-rotated <name>——这只是 Portunus 无法独立验证的人工声明,并非真正的轮换触发器。此功能用于查找已经泄漏的密钥;它不会阻止下一次将密钥粘贴到聊天窗口,也绝不会阻止 resolve/inject(仅提供建议——与上文的访问控制强制执行属于独立、正交的控制;某个引用可以同时被标记为存在泄漏风险且仍完全可访问)。所有发现始终仅包含 {reference, file, line}——在任何层(CLI、MCP、UI)都不会包含泄漏值。CLI:portunus leak-scan [--json]、portunus leak-scan config add-path/remove-path/show、portunus leak status [name]、portunus leak mark-rotated <name>。MCP:portunus_run_leak_scan、portunus_leak_status、portunus_leak_scan_config_ show/add_path/remove_path、portunus_leak_mark_rotated——连接 MCP 的 Agent 可以触发扫描(需用户明确决定),但扫描范围仅限人工已配置的路径;绝不会扫描该集合之外的内容。
泄露标记会随引用显示在各处,而不只是在 Settings 中。 泄露引用旁会显示一个小型 ⚠ leak: <severity> 徽标(悬停提示为“在 N 个对话中泄露”——统计的是不同文件数,而非原始发现数量,因为同一份记录可能在多行中匹配到同一个密钥),显示位置包括 Console(带有专属的“Leaked”筛选项)、Vault Map、Project Explorer 和 DetailDrawer;其中 DetailDrawer 还会显示完整的文件:行历史记录和“Mark rotated”按钮。此标记独立于轮换请求标记(RotationBadge)——Agent 请求轮换和检测到泄露是两回事,并非同一个信号。
Git 仓库历史是一等扫描目标,而不仅仅是纯文本日志文件。
portunus leak-scan config add-repo <path> 会扫描仓库的完整历史记录(通过 git log --all -p 检查每个分支和每次提交),而不只是当前工作树——这正是此前用来证实没有任何泄露凭据进入过此代码库自身历史记录的技术,现在已内置其中,无需再手动导出并扫描。每次运行都会完整重新扫描(Git 历史可以改写,不像只追加的日志文件)——仓库历史记录远小于最初促使我们实现增量扫描的日志语料库。每项发现都会按来源分类:log / local 文件,或 git-history——对于后者,还会标明仓库的 GitHub 远程仓库是 public、private 还是 unknown(绝不猜测;通过 gh repo view 查询,与桌面应用自动更新程序使用的 gh-CLI/自有凭据方式相同)。公开仓库中的发现会在所有显示位置以最醒目的方式呈现——这是此功能能够揭示的、与严重程度最相关的事实。CLI:portunus leak-scan config add-repo/remove-repo/show-repos;使用 portunus leak status <name> --detail 查看每项发现的完整分类。
调度 leak-scan(cron / CI)
portunus leak-scan 开箱即用,适用于 cron/CI:它不会提示用户,并且有新发现时退出码为 1,否则为 0。
# crontab -e
0 9 * * * PORTUNUS_HOME=/path/to/home PORTUNUS_PASSPHRASE_FILE=/path/to/passphrase \
portunus leak-scan --json >> /path/to/leak-scan.log 2>&1
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/portunus</string>
<string>leak-scan</string>
<string>--json</string>
</array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
在 CI 流水线中,应将非零退出码视为步骤失败(发现新的泄漏),而不是脚本错误——检查退出码,而不只是 stderr。切勿将计划运行配置为自动轮换或自动阻止任何内容;v1 刻意仅提供建议(docs/architecture.md §11)。
cd ui
npm install
npm run dev # http://localhost:3000
绑定地址。 所有启动 UI 的方式(npm run dev、npm start 和下面的独立命令)默认只监听 127.0.0.1,因此网络上的其他设备无法访问它。若要绑定到其他地址,请显式设置 PORTUNUS_UI_HOST,例如 PORTUNUS_UI_HOST=0.0.0.0 npm start。没有其他设置会改变绑定地址。npm 脚本使用 POSIX ${VAR:-default} 展开,因此请从 POSIX shell 中运行。npm run test:bind(在 npm run build 之后运行)会检查每个命令实际监听的地址。
请求防护。 ui/proxy.ts 会在每个 /api/* 路由前运行。如果非 GET 请求携带的 Origin 与 UI 自身不一致,或者其 Host 不是 localhost / 127.0.0.1 / [::1](这可以阻止 DNS 重绑定;设置 PORTUNUS_UI_HOST 后会跳过此检查),请求将被拒绝并返回 403。如果请求的 Content-Type 不是 application/json,则会被拒绝并返回 415。这意味着浏览器中打开的其他网页无法通过 UI 的 API 操控保险库。不带 Origin 标头的客户端(例如 curl)只要发送 JSON,仍可正常使用。
在非环回名称后部署(容器、反向代理)。 如果通过 localhost / 127.0.0.1 / [::1] 以外的名称访问 UI,例如另一个容器通过其 compose 服务名称调用 http://portunus-ui:7802,请预先设置 PORTUNUS_UI_HOST。否则,该调用方发出的所有写入请求都会被拒绝,并返回 403 {"error":"non-loopback Host rejected"},而读取请求仍可正常使用。深度检查 GET /api/health 会对自身请求执行相同的 Host 检查,因此该调用方会看到 status: "down"(HTTP 503)以及失败的 ui-write-host 检查,而不是绿色结果。?shallow=1 会跳过此检查。注意,容器内通过 curl 请求 localhost 的容器健康探针会发送环回 Host,仍会通过;请使用真实调用方使用的同一名称探测健康状态。
作为受监管的服务运行(L2 插件生命周期)
UI 也可以构建为自包含、可由主机监管的服务——GET /api/health 会返回 portunus health --json 的结果({status: ok|degraded|down, checks};状态为 down 时返回 HTTP 503),以及上文所述的 ui-write-host 检查;GET /api/health?shallow=1 会返回 {"status":"ok"},而不调用 CLI(桌面应用的就绪状态轮询会使用此接口);next.config.mjs 设置了 output: "standalone",因此可以直接监管单一固定入口点:
cd ui
npm run build
cp -r .next/static .next/standalone/.next/static # standalone mode doesn't do this automatically
PORT=7802 HOSTNAME="${PORTUNUS_UI_HOST:-127.0.0.1}" node .next/standalone/server.js
# or, equivalently: PORT=7802 npm run start:standalone
如示例所示,为此命令设置 HOSTNAME。Next 的独立版 server.js 会绑定到 HOSTNAME 指定的地址;如果未指定,则会回退到 0.0.0.0(所有网络接口)。Docker 和某些 shell 会将机器名称导出为 HOSTNAME。直接启动 server.js 的监管程序必须自行传入 HOSTNAME=127.0.0.1,桌面应用的 sidecar 已经这样设置。
运行端口为 7802(在 manifest.json 的 ui.url 中声明),与 Portunus 在 Pantheon 主机的共享清单中注册的端口一致。
桌面应用(macOS)
原生 Tauri v2 外壳将同一界面封装为菜单栏应用——不用再每次都运行 cd ui && npm run dev。这是附加功能,而非替代方案:npm run dev 仍然完全可用。有关它是什么(以及不是什么),请参阅
docs/architecture.md。
安装(在本地构建——设计详情请参阅 .pHive/epics/portunus-desktop-app/,或者在发布新版后从最新的 GitHub release 下载 .zip):
cd ui
npm install && npm run build
cargo install tauri-cli --version "^2" --locked # once
cargo tauri build
cp -r src-tauri/target/release/bundle/macos/Portunus.app /Applications/
open /Applications/Portunus.app
首次绕过 Gatekeeper。 此应用采用临时签名,未经公证——这是经过审慎考虑的 v1 范围决策(面向一位用户、一台设备,并非公开分发场景;完整理由请参阅设计文档),并非疏忽。macOS 首次启动时会拒绝打开它。前往系统设置 → 隐私与安全性,滚动到底部,在 Portunus 警告旁点击仍要打开,然后再次启动应用。此操作只需进行一次——之后自动更新不会再次触发此限制。
使用方法。 菜单栏中会出现一个托盘图标,包含:打开 Vault、检查更新…、登录时启动、退出。关闭窗口会将其隐藏(应用仍在托盘中运行)——要真正停止应用,请使用退出。应用每 6 小时自动检查一次更新(也可通过托盘菜单手动检查),检查对象是此仓库自己的 GitHub releases,使用的是你已通过身份验证的 gh CLI——应用中绝不会嵌入令牌。安装任何内容前,都会显示确认对话框;不会在后台静默替换应用。
如果出现问题(应用崩溃或被强制退出后,后台服务可能仍在运行):运行 pkill -f "server.js.*portunus" 将其清除;下次启动时无论如何都会选用一个新端口。
库 API
from portunus import Registry, AuditChain, Broker, Resolver, GcloudBackend
registry = Registry()
broker = Broker(registry, AuditChain())
resolver = Resolver(registry, GcloudBackend(project="my-proj"), broker)
# Inject ONLY into the boundary call; the value is never returned to us.
resolver.resolve_call(
"Authorization: Bearer {{secret:shared-anthropic}}",
boundary=lambda header: http_post(url, headers={"Authorization": header}),
)
开发
pip install -e ".[test]"
pytest -q
测试使用内存中的 MockBackend 和隔离的 PORTUNUS_HOME,因此不会触及 GCP 或真实状态。关键测试会断言:解析后的值绝不会出现在返回值、审计日志或权限不是 0600 的文件中。
目录结构
src/portunus/
registry.py reference registry (name -> SM path); tags/provider/project/env; description/
purpose/injected_as metadata; list_by_project() browse query; no value field
backend.py ARCA — SecretBackend protocol; MockBackend (tests); GcloudBackend (keyless WIF,
multi-project via VaultBinding); SyncingBackend (recency-aware sync-down cache,
falls back to the last cached value on a real connectivity failure);
AWSSecretsManagerBackend/VaultServerBackend/InfisicalBackend/DopplerBackend/
OnePasswordConnectBackend/AzureKeyVaultBackend (honest stubs, see docs/architecture.md)
localvault.py ARCA — LocalEncryptedBackend, the Stage 1 default (encrypted at rest)
auth.py keyless WIF/OIDC credential minting (GCP + AWS token exchange); never logs/
returns/prints minted credentials
discover.py ARCA discovery — read-only enumeration of a live provider project's secrets
(names/labels only); structurally cannot reach a value (no backend import)
broker.py Petitio — grant / gate / approve + lifecycle guard, wired to audit; Identity +
requester feeds real, opt-in access-control enforcement (`roles enforce on`)
roles.py PolicyRecord store (org/project/env/repo scope + principal) + roles.evaluate()
-- the single seam all scope/precedence logic lives in, per check_injectable()
audit.py tamper-evident hash-chain access log
resolver.py OSTIARIUS — boundary-only {{secret:NAME}} resolution ← the core
adapters.py boundary injection adapters (env var, file) -- see Roadmap for HTTP-client
intent.py semantic front door — natural language -> tag set + intent_kind
(fetch/add/rotate/list) (portunus ask)
cli.py OSTIARIUS — the `portunus` tool (incl. the harness-side `drop`)
docs/architecture.md adopter-facing reference: component diagram, ARCA backend-selection
precedence, Petitio today-vs-tomorrow, the request/resolve sequence
ui/ standalone localhost-only UI (Console / Vault Map / Ask Bar)
.claude/skills/ thin Claude skills wrapping the CLI/MCP surface: portunus-ask (fetch),
portunus-drop (create, single/bulk), portunus-vault-setup (bindings/sync)
.github/ISSUE_TEMPLATE/adapter-request.yaml request a new ARCA backend
manifest.json Dostal plugin manifest (type: core, engine: tool)
许可证
MIT © 2026 Mathew Dostal
支持此项目
始终免费且开源。以下是几种支持方式,也欢迎打声招呼:
- 使用它、点星、提交问题。 说实话,这是对开源项目最好的支持。→ 此项目
- 聘请我。 我提供兼职 CTO 和咨询服务,帮助修复并扩展技术栈。→ mdostal.com/contact
- 如果它为你节省了时间,可以**请我喝杯咖啡**。
- 更多类似工具 → tools.mdostal.com
- 终端之外的生活 → life.mdostal.com
- 我们在 Firefly Events 正在打造的产品——活动发现,每天从 7+ 个来源汇集 8,000+ 场活动 → ff.events
如果这些内容对你有用,我随时愿意聊聊。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。