(input: Partial<DecisionEvent>)
| 101 | * - a HIGH-tier secret (redact engine) in any free-text field. |
| 102 | */ |
| 103 | export function validateDecide(input: Partial<DecisionEvent>): ValidateResult { |
| 104 | if (!input.decision || typeof input.decision !== "string" || !input.decision.trim()) { |
| 105 | return { ok: false, error: "decision text is required" }; |
| 106 | } |
| 107 | const scope = input.scope ?? "repo"; |
| 108 | if (!DECISION_SCOPES.includes(scope)) { |
| 109 | return { ok: false, error: `invalid scope "${scope}"; must be ${DECISION_SCOPES.join("|")}` }; |
| 110 | } |
| 111 | const source = input.source ?? "agent"; |
| 112 | if (!DECISION_SOURCES.includes(source)) { |
| 113 | return { ok: false, error: `invalid source "${source}"; must be ${DECISION_SOURCES.join("|")}` }; |
| 114 | } |
| 115 | if (input.confidence !== undefined) { |
| 116 | const c = Number(input.confidence); |
| 117 | if (!Number.isInteger(c) || c < 1 || c > 10) { |
| 118 | return { ok: false, error: "confidence must be integer 1-10" }; |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // Scan ALL stored free-text — incl. branch/issue, which are surfaced (and emitted raw |
| 123 | // via --json), so they must not carry secrets or injection either (Codex finding). |
| 124 | const freeText = [input.decision, input.rationale, input.alternatives_considered, input.branch, input.issue] |
| 125 | .filter((s): s is string => typeof s === "string") |
| 126 | .join("\n"); |
| 127 | |
| 128 | if (hasInjection(freeText)) { |
| 129 | return { ok: false, error: "decision contains instruction-like content (injection), rejected" }; |
| 130 | } |
| 131 | const redacted = scan(freeText); |
| 132 | if (redacted.counts.HIGH > 0) { |
| 133 | return { |
| 134 | ok: false, |
| 135 | error: `decision contains a HIGH-tier secret (${redacted.counts.HIGH} finding(s)); rotate + remove it, do not log secrets`, |
| 136 | }; |
| 137 | } |
| 138 | // MEDIUM = PII / credential-shaped content. The taxonomy says "confirm via |
| 139 | // AskUserQuestion", but this store is NON-INTERACTIVE and syncs cross-machine, |
| 140 | // so there is no confirm path — fail closed rather than silently persist + sync a |
| 141 | // secret that later resurfaces into agent context. |
| 142 | if (redacted.counts.MEDIUM > 0) { |
| 143 | return { |
| 144 | ok: false, |
| 145 | error: `decision contains MEDIUM-tier sensitive content (${redacted.counts.MEDIUM} finding(s): PII or credential-shaped). This store is non-interactive and syncs across machines, so it fails closed — remove or rephrase the value before logging.`, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | const event: DecisionEvent = { |
| 150 | id: input.id || randomUUID(), |
| 151 | kind: "decide", |
| 152 | decision: input.decision.trim(), |
| 153 | rationale: input.rationale, |
| 154 | alternatives_considered: input.alternatives_considered, |
| 155 | scope, |
| 156 | branch: input.branch || undefined, |
| 157 | issue: input.issue || undefined, |
| 158 | date: input.date || new Date().toISOString(), |
| 159 | session: input.session, |
| 160 | source, |
no test coverage detected