fix(agent,secret): enforce CRYPTO_SECRET for agent deploy (#31) and guard unscoped vault purge (#33)

#31: HeicodeDeployAgent now refuses to deploy unless CRYPTO_SECRET is explicitly
configured, so the per-agent access_token is sealed with a key that survives a
container restart. common.CryptoSecret is never literally "" (defaults to
uuid/SessionSecret), so the sealAgentToken plaintext fallback was effectively
unreachable; the real hazard is an ephemeral random seal key making tokens
undecryptable after restart. Dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true.
Verified prod container has CRYPTO_SECRET set (64 chars) -> deploy stays allowed.

#33: StartSecretPurgeTask refuses to start a whole-vault purge when
HEICODE_SECRET_PURGE_NAME_PREFIX is empty unless HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true,
so HM never permanently purges another tenant's soft-deleted secrets in a shared
vault. Logs the resolved purge scope at startup.

Both gates extracted into pure, unit-tested helpers (agentTokenSealKeyConfigured,
secretPurgeScopeAllowed). Affects: Manager only (Agent deploy + Secret lifecycle).
No Client/Swarm/billing/audit schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:33:47 +08:00
co-authored by Claude Opus 4.8
parent 818f1412d5
commit 8fe1f5e131
4 changed files with 89 additions and 0 deletions
+30
View File
@@ -214,6 +214,19 @@ func StartSecretPurgeTask() {
if strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")) == "" {
return // no vault configured — nothing to purge
}
// #33: a destructive purge that scans the WHOLE vault is only safe when
// HM owns the vault exclusively. If no name prefix scopes purging to
// HM-managed secrets, require an explicit opt-in (HEICODE_SECRET_PURGE_
// VAULT_EXCLUSIVE=true) so HM never permanently purges another tenant's
// soft-deleted secrets that happen to live in a shared vault.
purgePrefix := strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", ""))
vaultExclusive := common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE", false)
allowed, scopeDesc := secretPurgeScopeAllowed(purgePrefix, vaultExclusive)
if !allowed {
common.SysLog("secret purge task NOT started: " + scopeDesc)
return
}
common.SysLog("secret purge scope: " + scopeDesc)
intervalHours := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_INTERVAL_HOURS", 24)
if intervalHours < 1 {
intervalHours = 24
@@ -231,6 +244,23 @@ func StartSecretPurgeTask() {
})
}
// secretPurgeScopeAllowed decides whether the destructive vault purge may run,
// given the configured name prefix and the vault-exclusive opt-in (#33). A purge
// that scans the WHOLE vault (empty prefix) is only safe when HM owns the vault
// exclusively, so it must be explicitly opted in. Returns the decision plus a
// human-readable scope/refusal description for the startup log. Pure helper for
// unit testing without touching process env.
func secretPurgeScopeAllowed(namePrefix string, vaultExclusive bool) (bool, string) {
prefix := strings.TrimSpace(namePrefix)
if prefix == "" {
if !vaultExclusive {
return false, "HEICODE_SECRET_PURGE_NAME_PREFIX is empty and HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE!=true — refusing to purge an entire (possibly shared) vault. Set a name prefix to scope to HM-managed secrets, or set HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true only if this vault is exclusive to HM."
}
return true, "ENTIRE vault (HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true, no name prefix)"
}
return true, "secrets with name prefix '" + prefix + "'"
}
func secretPurgeRetentionDays() int {
d := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_RETENTION_DAYS", 30)
if d < 1 {