akm-opencode 0.9.8202609020939 → 0.10.261001-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,48 +0,0 @@
1
- // Single source of truth for the akm-cli version contract.
2
- //
3
- // Both the Claude hook (claude/hooks/akm-hook.ts) and the OpenCode plugin
4
- // (opencode/index.ts) validate the user's installed akm-cli against this exact
5
- // range. Keeping it here — alongside the vendored semver matcher — means a
6
- // version bump is a one-line change in one file instead of three drifting
7
- // copies (a hand-rolled `minor === 8` check previously diverged here).
8
- //
9
- // The matcher is the vendored `satisfies()` rather than the npm `semver`
10
- // package because the Claude hook runs as a bare Bun script with no
11
- // node_modules at hook-execution time. This module is vendored into the
12
- // published OpenCode tarball by opencode/scripts/vendor-shared.mjs, so the
13
- // same code path runs on both sides.
14
- //
15
- // A single caret clause anchored at the stable release covers the whole
16
- // supported line: `^0.9.8` admits stable 0.9.8 and later 0.9.x releases.
17
- // 0.9.8 is the compatibility floor, and the reason is a one-way one: 0.9.8
18
- // adds state migrations 025 and 026, so once any 0.9.8 command opens
19
- // `state.db` an older CLI refuses to open it at all. A home these plugins
20
- // have driven is therefore a 0.9.8+ home, and admitting 0.9.7 would point
21
- // the gate at a CLI that cannot read it. Its ref grammar, progressive-search
22
- // metadata, token-budgeted curate packing, workflow lifecycle, and
23
- // task/workflow wire contracts are the ones these plugins implement.
24
- //
25
- // KNOWN GAP: NO prerelease satisfies this range — not 0.9.8-rc.1, and not a
26
- // *future* line such as 0.9.9-rc.1. That is node-semver's documented behavior
27
- // and the vendored matcher reproduces it: a prerelease only satisfies a range
28
- // whose lower bound is a prerelease with the same major.minor.patch. Admitting
29
- // a prerelease line again is an explicit one-clause edit here
30
- // (`^0.9.8 || ^0.9.9-rc.1`) when such a build actually needs testing — a
31
- // deliberate opt-in rather than a range that silently accepts untested
32
- // prereleases.
33
- //
34
- // Earlier 0.9 releases are deliberately excluded as well: accepting them
35
- // would silently pass the version gate onto a CLI with retired ref and
36
- // workflow contracts, or one that cannot open a migrated state.db.
37
-
38
- import { satisfies } from "./vendor-semver"
39
-
40
- export const AKM_VERSION_RANGE = "^0.9.8"
41
-
42
- /**
43
- * True when `version` is a valid semver string that satisfies
44
- * {@link AKM_VERSION_RANGE}. Non-strings (e.g. a failed probe) return false.
45
- */
46
- export function satisfiesAkmVersionRange(version: string | null | undefined): boolean {
47
- return typeof version === "string" && satisfies(version, AKM_VERSION_RANGE)
48
- }
@@ -1,113 +0,0 @@
1
- export type AkmFeedbackSignal = {
2
- ref: string
3
- polarity: "positive" | "negative"
4
- confidence: number
5
- source:
6
- | "explicit_user_feedback"
7
- | "tool_success"
8
- | "tool_failure"
9
- | "retrospective_positive"
10
- | "retrospective_negative"
11
- | "workflow_completion"
12
- | "proposal_acceptance"
13
- | "proposal_rejection"
14
- | "curator_assessment"
15
- note: string
16
- sessionId?: string
17
- harness: "claude-code" | "opencode"
18
- }
19
-
20
- /**
21
- * Retrospective feedback matchers. Both plugins classify a user message as
22
- * after-the-fact praise or complaint about assets the session already touched,
23
- * so the grammar lives here rather than in one harness: the two sides must
24
- * agree on what counts as a signal, or the same message produces opposite
25
- * verdicts depending on the harness.
26
- *
27
- * AKM_RETROSPECTIVE_FEEDBACK_PATTERN / AKM_RETROSPECTIVE_NEGATIVE_PATTERN let
28
- * an operator retune the vocabulary (other languages, project jargon). A
29
- * user-supplied pattern is untrusted input, so an invalid one falls back to the
30
- * default instead of throwing at module load and taking the plugin down with
31
- * it.
32
- */
33
- export function createRetrospectiveFeedbackRegex(): RegExp {
34
- const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
35
- try {
36
- return new RegExp(pattern, "i")
37
- } catch {
38
- return /\b(thanks|perfect|worked)\b/i
39
- }
40
- }
41
-
42
- export function createRetrospectiveNegativeRegex(): RegExp {
43
- const pattern = process.env.AKM_RETROSPECTIVE_NEGATIVE_PATTERN ?? "\\b(wrong|failed|broken|didn't work|did not work|bad)\\b"
44
- try {
45
- return new RegExp(pattern, "i")
46
- } catch {
47
- return /\b(wrong|failed|broken|didn't work|did not work|bad)\b/i
48
- }
49
- }
50
-
51
- /**
52
- * Explicit corrections ("that was wrong") are a stronger, narrower signal than
53
- * the tunable negative matcher, so this one is deliberately not overridable.
54
- */
55
- export function createExplicitCorrectionRegex(): RegExp {
56
- return /\b(this was wrong|that was wrong|you were wrong|incorrect|not correct)\b/i
57
- }
58
-
59
- export function getAutoFeedbackMinConfidence(): number {
60
- const raw = Number(process.env.AKM_AUTO_FEEDBACK_MIN_CONFIDENCE ?? "0.6")
61
- return Number.isFinite(raw) ? raw : 0.6
62
- }
63
-
64
- export function classifyFeedbackSignal(input: {
65
- ref: string
66
- polarity: "positive" | "negative"
67
- harness: "claude-code" | "opencode"
68
- sessionId?: string
69
- directInput?: boolean
70
- explicitUser?: boolean
71
- retrospective?: boolean
72
- workflowCompletion?: boolean
73
- proposalAction?: "accept" | "reject"
74
- curatorAssessment?: boolean
75
- note?: string
76
- }): AkmFeedbackSignal {
77
- let source: AkmFeedbackSignal["source"] = input.polarity === "positive" ? "tool_success" : "tool_failure"
78
- let confidence = input.directInput ? 0.65 : 0.25
79
-
80
- if (input.explicitUser) {
81
- source = "explicit_user_feedback"
82
- confidence = 0.95
83
- } else if (input.retrospective) {
84
- source = input.polarity === "positive" ? "retrospective_positive" : "retrospective_negative"
85
- confidence = input.polarity === "positive" ? 0.7 : 0.8
86
- } else if (input.workflowCompletion) {
87
- source = "workflow_completion"
88
- confidence = 0.85
89
- } else if (input.proposalAction === "accept") {
90
- source = "proposal_acceptance"
91
- confidence = 0.95
92
- } else if (input.proposalAction === "reject") {
93
- source = "proposal_rejection"
94
- confidence = 0.85
95
- } else if (input.curatorAssessment) {
96
- source = "curator_assessment"
97
- confidence = 0.75
98
- }
99
-
100
- return {
101
- ref: input.ref,
102
- polarity: input.polarity,
103
- confidence,
104
- source,
105
- note: input.note ?? `${input.harness} auto: source=${source}; confidence=${confidence.toFixed(2)}`,
106
- sessionId: input.sessionId,
107
- harness: input.harness,
108
- }
109
- }
110
-
111
- export function shouldSubmitAutomaticFeedback(signal: AkmFeedbackSignal): boolean {
112
- return signal.confidence >= getAutoFeedbackMinConfidence()
113
- }
@@ -1,95 +0,0 @@
1
- import { appendFileSync, existsSync, mkdirSync } from "node:fs"
2
- import path from "node:path"
3
- import { redactObject } from "./redaction"
4
- // Memory events log session activity (refs touched, outcomes, scope). Even
5
- // post-redaction this is a privileged record, so the directory + file are
6
- // locked to owner-only and events.jsonl is size-capped like every other
7
- // append-only state file. Both primitives live in ./state-files so the Claude
8
- // hook and this module share one implementation.
9
- import { chmodSafe, rotateIfOversized } from "./state-files"
10
-
11
- // Exactly the event names some shipped surface emits. The union used to carry
12
- // 18 more (workflow_*, candidate_*, session_ended, ...) that no call site ever
13
- // wrote — a vocabulary describing pipelines that were removed or never built.
14
- // Keep this list emitter-driven: add a name when something emits it, not in
15
- // anticipation.
16
- export type AkmMemoryEventType =
17
- | "session_started"
18
- | "prompt_recall"
19
- | "tool_observation"
20
- | "tool_batch_observation"
21
- | "tool_ref_observed"
22
- | "workflow_step"
23
- | "task_created"
24
- | "task_completed"
25
- | "subagent_started"
26
- | "post_compact_summary"
27
- | "feedback_recorded"
28
- // The opencode write gate (#99) emits exactly one of these per watched
29
- // write-tool invocation, with a named reason in `input.reason`. One name, not
30
- // two: fire and skip are the same event with different outcome.status.
31
- | "write_gate"
32
-
33
- export type AkmMemoryEvent = {
34
- version: 1
35
- timestamp: string
36
- harness: "claude-code" | "opencode"
37
- event: AkmMemoryEventType
38
- eventId?: string
39
- sessionId?: string
40
- project?: string
41
- agent?: string
42
- workflowRunId?: string
43
- scope?: {
44
- user?: string
45
- agent?: string
46
- run?: string
47
- channel?: string
48
- project?: string
49
- repo?: string
50
- branch?: string
51
- }
52
- input?: Record<string, unknown>
53
- memory?: Record<string, unknown>
54
- refs?: string[]
55
- outcome?: {
56
- status: "ok" | "skipped" | "blocked" | "failed"
57
- warnings?: string[]
58
- error?: string
59
- }
60
- redaction?: {
61
- redacted: boolean
62
- categories: string[]
63
- }
64
- }
65
-
66
- export function getHarnessStateDir(harness: "claude-code" | "opencode"): string {
67
- const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
68
- return path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
69
- }
70
-
71
- export function getEventLogPath(harness: "claude-code" | "opencode"): string {
72
- return path.join(getHarnessStateDir(harness), "events.jsonl")
73
- }
74
-
75
- export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok: true; redacted: boolean; categories: string[] } | { ok: false; error: string } {
76
- try {
77
- mkdirSync(path.dirname(filePath), { recursive: true })
78
- chmodSafe(path.dirname(filePath), 0o700)
79
- rotateIfOversized(filePath)
80
- const redacted = redactObject(event)
81
- const enriched = {
82
- ...redacted.value,
83
- redaction: {
84
- redacted: redacted.redacted,
85
- categories: redacted.categories,
86
- },
87
- }
88
- const created = !existsSync(filePath)
89
- appendFileSync(filePath, `${JSON.stringify(enriched)}\n`)
90
- if (created) chmodSafe(filePath, 0o600)
91
- return { ok: true, redacted: redacted.redacted, categories: redacted.categories }
92
- } catch (error: unknown) {
93
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
94
- }
95
- }
@@ -1,74 +0,0 @@
1
- export type RecallDecision = {
2
- shouldRecall: boolean
3
- reason:
4
- | "explicit-akm"
5
- | "long-prompt"
6
- | "memory-intent"
7
- | "workflow-intent"
8
- | "agent-dispatch"
9
- | "command-dispatch"
10
- | "wiki-intent"
11
- | "proposal-intent"
12
- | "release-intent"
13
- | "coding-task"
14
- | "active-workflow"
15
- | "recent-asset-failure"
16
- | "skip-short"
17
- | "skip-chitchat"
18
- | "skip-low-signal"
19
- query: string
20
- scopeHints?: string[]
21
- }
22
-
23
- import { extractAkmRefsFromString } from "./ref-extraction"
24
-
25
- export function shouldRecall(prompt: string, options?: { activeWorkflow?: boolean; recentAssetFailure?: boolean }): RecallDecision {
26
- const text = prompt.trim()
27
- const lower = text.toLowerCase()
28
- const scopeHints: string[] = []
29
- if (!text) return { shouldRecall: false, reason: "skip-low-signal", query: "", scopeHints }
30
- if (options?.activeWorkflow) return { shouldRecall: true, reason: "active-workflow", query: text, scopeHints: ["workflow"] }
31
- if (options?.recentAssetFailure) return { shouldRecall: true, reason: "recent-asset-failure", query: text, scopeHints }
32
- if (text.length < 4 || /^(ok|thanks|thank you|yes|no|continue|go ahead|sure|cool)$/i.test(lower)) {
33
- return { shouldRecall: false, reason: "skip-short", query: text, scopeHints }
34
- }
35
- if (/\b(hi|hello|how are you|good morning|good night)\b/i.test(lower) && text.length < 40) {
36
- return { shouldRecall: false, reason: "skip-chitchat", query: text, scopeHints }
37
- }
38
- // Reuse the resolver-facing parser rather than carrying a second, stale
39
- // grammar here. AKM 0.9.7 refs are [bundle//]conceptId[#fragment]; retired
40
- // type:name strings must not turn an otherwise low-signal prompt into an
41
- // explicit AKM recall.
42
- if (/\bakm\b|\bbundle\b/.test(lower) || extractAkmRefsFromString(text).length > 0) {
43
- return { shouldRecall: true, reason: "explicit-akm", query: text, scopeHints: ["akm"] }
44
- }
45
- if (/\b(remember|memory|prior session|previous decision)\b/.test(lower)) {
46
- return { shouldRecall: true, reason: "memory-intent", query: text, scopeHints: ["memory"] }
47
- }
48
- if (/\b(workflow|resume|complete step|next step|blocked step)\b/.test(lower)) {
49
- return { shouldRecall: true, reason: "workflow-intent", query: text, scopeHints: ["workflow"] }
50
- }
51
- if (/\b(dispatch|agent|subagent|reviewer|planner|curator)\b/.test(lower)) {
52
- return { shouldRecall: true, reason: "agent-dispatch", query: text, scopeHints: ["agent"] }
53
- }
54
- if (/\b(command|slash command|run the bundle command)\b/.test(lower)) {
55
- return { shouldRecall: true, reason: "command-dispatch", query: text, scopeHints: ["command"] }
56
- }
57
- if (/\b(wiki|docs|knowledge base|ingest|lint)\b/.test(lower)) {
58
- return { shouldRecall: true, reason: "wiki-intent", query: text, scopeHints: ["wiki"] }
59
- }
60
- if (/\b(proposal|accept|reject|diff proposal|review proposals)\b/.test(lower)) {
61
- return { shouldRecall: true, reason: "proposal-intent", query: text, scopeHints: ["proposal"] }
62
- }
63
- if (/\b(release|publish|semver|version bump|bump version|tag the release|cut a release)\b/.test(lower)) {
64
- return { shouldRecall: true, reason: "release-intent", query: text, scopeHints: ["workflow", "command"] }
65
- }
66
- if (
67
- text.length >= 16
68
- && /\b(review|pull request|\bpr\b|diff|refactor|type hints|typing|readability|readable|debug|diagnose|traceback|exception|exceptions|scaffold|unit test|tests|test|changelog|release notes|deployment|deploy|rollback|runbook|error code|healthcheck|linters|lint|format|formatting|naming conventions|convention|style guide|onboarding|new hire|new engineer|api keys|secrets|plan|design|architecture|tradeoffs|build|implement|fix|update)\b/.test(lower)
69
- ) {
70
- return { shouldRecall: true, reason: "coding-task", query: text, scopeHints: ["code"] }
71
- }
72
- if (text.length >= 120) return { shouldRecall: true, reason: "long-prompt", query: text, scopeHints }
73
- return { shouldRecall: false, reason: "skip-low-signal", query: text, scopeHints }
74
- }
@@ -1,292 +0,0 @@
1
- import { inspect } from "node:util"
2
-
3
- export type RedactionResult = {
4
- text: string
5
- redacted: boolean
6
- categories: string[]
7
- }
8
-
9
- export type RedactedObjectResult<T> = {
10
- value: T
11
- redacted: boolean
12
- categories: string[]
13
- }
14
-
15
- const SENSITIVE_KEY_RE = /(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN|GH_TOKEN|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|AZURE_[A-Z0-9_]*KEY|SLACK_BOT_TOKEN|SLACK_TOKEN|PASSWORD|PASSWD|SECRET|CONNECTION_STRING|DATABASE_URL|AKM_[A-Z0-9_]*TOKEN)/i
16
- const SIMPLE_REPLACEMENTS: Array<{ category: string; pattern: RegExp; replacement: string }> = [
17
- {
18
- category: "private_key",
19
- pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
20
- replacement: "[REDACTED:PRIVATE_KEY]",
21
- },
22
- {
23
- category: "bearer_token",
24
- pattern: /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
25
- replacement: "[REDACTED:BEARER_TOKEN]",
26
- },
27
- {
28
- category: "github_token",
29
- pattern: /\bgh[pousr]_[A-Za-z0-9_]+\b/g,
30
- replacement: "[REDACTED:GITHUB_TOKEN]",
31
- },
32
- {
33
- category: "github_pat",
34
- pattern: /\bgithub_pat_[A-Za-z0-9_]+\b/g,
35
- replacement: "[REDACTED:GITHUB_PAT]",
36
- },
37
- {
38
- category: "openai_key",
39
- pattern: /\bsk-[A-Za-z0-9_-]+\b/g,
40
- replacement: "[REDACTED:OPENAI_API_KEY]",
41
- },
42
- {
43
- category: "slack_token",
44
- pattern: /\bxox[baprs]-[A-Za-z0-9-]+\b/g,
45
- replacement: "[REDACTED:SLACK_TOKEN]",
46
- },
47
- // 0.8.0 release-hardening: AWS access key (AKIA prefix, 16 alphanumerics).
48
- // IAM user access keys are 20 chars total starting with AKIA; STS temporary
49
- // creds use ASIA but those are paired with a session token and short-lived,
50
- // so the AKIA prefix is the high-value leak.
51
- {
52
- category: "aws_access_key",
53
- pattern: /\bAKIA[0-9A-Z]{16}\b/g,
54
- replacement: "[REDACTED:AWS_ACCESS_KEY_ID]",
55
- },
56
- // Google Cloud API key (AIza prefix, 35 base64url-safe chars). Pattern is
57
- // documented at cloud.google.com/docs/authentication/api-keys.
58
- {
59
- category: "google_api_key",
60
- pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
61
- replacement: "[REDACTED:GOOGLE_API_KEY]",
62
- },
63
- // Stripe live-mode secret/publishable keys (sk_live_, pk_live_). Stripe
64
- // documents these as the high-severity leak surface in their API docs.
65
- {
66
- category: "stripe_live_key",
67
- pattern: /\b(?:sk|pk)_live_[0-9a-zA-Z]+\b/g,
68
- replacement: "[REDACTED:STRIPE_LIVE_KEY]",
69
- },
70
- // Stripe test-mode keys (sk_test_, pk_test_). Lower severity but still
71
- // sensitive — test keys often grant access to test webhooks and dashboards
72
- // that mirror production object shapes.
73
- {
74
- category: "stripe_test_key",
75
- pattern: /\b(?:sk|pk)_test_[0-9a-zA-Z]+\b/g,
76
- replacement: "[REDACTED:STRIPE_TEST_KEY]",
77
- },
78
- // Raw `Authorization: <token>` header without the `Bearer` keyword. Some
79
- // services (legacy AWS, internal APIs, OAuth1 signed requests) send the
80
- // token directly. The Bearer-form pattern above handles the common case.
81
- // We require at least 12 chars of token to avoid matching `Authorization: ?`
82
- // or other very short placeholder values.
83
- {
84
- category: "authorization_header",
85
- pattern: /(Authorization:\s+)(?!Bearer\b)([A-Za-z0-9._~+/=-]{12,})/gi,
86
- replacement: "$1[REDACTED:AUTHORIZATION]",
87
- },
88
- // WS-7b: Database connection strings — redact credentials portion.
89
- // Matches postgres://, mysql://, mongodb+srv://, mongodb://, redis:// with user:pass@ form.
90
- {
91
- category: "connection_string",
92
- pattern: /((?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/)([^@\s]+@)([^\s"'`,]+)/gi,
93
- replacement: "$1[REDACTED:CREDENTIALS]@$3",
94
- },
95
- // WS-7b: AWS ARN structures with embedded account IDs (12-digit).
96
- // Matches arn:aws:...:123456789012:... patterns.
97
- {
98
- category: "aws_arn",
99
- pattern: /arn:aws:[a-z0-9_-]+:[a-z0-9-]*:(\d{12}):[^\s"'`,]*/gi,
100
- replacement: "arn:aws:...[REDACTED:ACCOUNT_ID]:...",
101
- },
102
- // WS-7b: JWT-shaped tokens — three base64url segments separated by dots (eyJ prefix).
103
- {
104
- category: "jwt_token",
105
- pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
106
- replacement: "[REDACTED:JWT_TOKEN]",
107
- },
108
- ]
109
-
110
- /**
111
- * High-entropy string redaction (WS-7b).
112
- *
113
- * Generic strings >= 32 chars matching [A-Za-z0-9+/=_-]+ that look like
114
- * base64/hex secrets. Disabled by default to avoid false positives (e.g.
115
- * long identifiers, file paths, etc.). Opt in by setting the env var:
116
- *
117
- * AKM_REDACT_HIGH_ENTROPY=1
118
- *
119
- * The threshold is 32 chars, configurable via AKM_REDACT_ENTROPY_MIN_LEN.
120
- */
121
- const HIGH_ENTROPY_ENABLED = process.env.AKM_REDACT_HIGH_ENTROPY === "1"
122
- const HIGH_ENTROPY_MIN_LEN = Number.parseInt(process.env.AKM_REDACT_ENTROPY_MIN_LEN ?? "32", 10)
123
- const HIGH_ENTROPY_RE = HIGH_ENTROPY_ENABLED
124
- ? new RegExp(`\\b[A-Za-z0-9+/=_-]{${Math.max(32, HIGH_ENTROPY_MIN_LEN)},}\\b`, "g")
125
- : null
126
-
127
- /**
128
- * Opt-in PII redaction patterns. Off by default because financial / phone
129
- * shapes appear in legitimate logs (timestamps, counters, identifiers) and
130
- * over-redaction makes session logs unreadable. Enable for hosts that
131
- * legitimately process PII through the agent loop:
132
- *
133
- * AKM_REDACT_PII=1
134
- *
135
- * Patterns covered:
136
- * - Credit-card-shaped 13–19 digit sequences with optional `[- ]` delimiters
137
- * (Luhn is intentionally NOT verified — we redact aggressively when opt-in)
138
- * - US Social Security Numbers in `\d{3}-\d{2}-\d{4}` form (delimited only;
139
- * bare 9-digit numbers are too noisy)
140
- * - US/international phone numbers in `[\+]?\d{1,3}[- .]?\d{3}[- .]?\d{3}[- .]?\d{4}` form
141
- */
142
- const PII_REDACTION_ENABLED = process.env.AKM_REDACT_PII === "1"
143
- const PII_PATTERNS: Array<{ category: string; pattern: RegExp; replacement: string }> = PII_REDACTION_ENABLED
144
- ? [
145
- {
146
- category: "credit_card",
147
- pattern: /\b(?:\d[ -]?){12,18}\d\b/g,
148
- replacement: "[REDACTED:CREDIT_CARD]",
149
- },
150
- {
151
- category: "ssn",
152
- pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
153
- replacement: "[REDACTED:SSN]",
154
- },
155
- {
156
- category: "phone",
157
- pattern: /(?<![\w./-])\+?\d{1,3}[- .]?\(?\d{3}\)?[- .]?\d{3}[- .]?\d{4}(?![\w./-])/g,
158
- replacement: "[REDACTED:PHONE]",
159
- },
160
- ]
161
- : []
162
-
163
- function uniq(values: string[]): string[] {
164
- return [...new Set(values)]
165
- }
166
-
167
- function redactAssignments(text: string, categories: string[]): string {
168
- const envLineRe = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/gm
169
- let next = text.replace(envLineRe, (match, rawKey: string) => {
170
- const key = String(rawKey)
171
- if (!SENSITIVE_KEY_RE.test(key)) return match
172
- categories.push(key.toLowerCase().includes("password") || key.toLowerCase().includes("passwd") ? "password" : "env_secret")
173
- categories.push("env_output")
174
- return `${key}: [REDACTED:${key}]`
175
- })
176
- const inlineAssignmentRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^\s"'`,;]+)/g
177
- next = next.replace(inlineAssignmentRe, (match, rawKey: string) => {
178
- const key = String(rawKey)
179
- if (!SENSITIVE_KEY_RE.test(key)) return match
180
- categories.push(key.toLowerCase().includes("password") || key.toLowerCase().includes("passwd") ? "password" : "env_secret")
181
- categories.push("env_output")
182
- return `${key}: [REDACTED:${key}]`
183
- })
184
- return next
185
- }
186
-
187
- function redactJsonLikePairs(text: string, categories: string[]): string {
188
- const pairRe = /(["']?)(password|passwd|secret|connectionString|apiKey|token|accessKey|secretKey)(\1\s*[:=]\s*)(["']?)([^\n,}"']+)(["']?)/gi
189
- return text.replace(pairRe, (_match, q1: string, key: string, sep: string) => {
190
- const upper = key.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()
191
- const category = /connection/i.test(key)
192
- ? "connection_string"
193
- : /pass/i.test(key)
194
- ? "password"
195
- : /secret/i.test(key)
196
- ? "secret"
197
- : "token"
198
- categories.push(category)
199
- return `${q1}${key}${q1}${sep}[REDACTED:${upper}]`
200
- })
201
- }
202
-
203
- // Redact `--flag value` / `-f value` CLI argv shapes where the flag name
204
- // contains a sensitive substring (password, token, secret, api-key, etc.).
205
- // This catches a class of leaks that the env-assignment and JSON-pair
206
- // regexes miss because CLI args use a space separator rather than `=` / `:`.
207
- function redactCliArgPairs(text: string, categories: string[]): string {
208
- const pairRe = /(--?[A-Za-z0-9_-]*?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|auth)[A-Za-z0-9_-]*)(\s+)(\S+)/gi
209
- return text.replace(pairRe, (_match, flag: string, sep: string) => {
210
- categories.push("cli_arg_secret")
211
- return `${flag}${sep}[REDACTED:CLI_ARG]`
212
- })
213
- }
214
-
215
- export function redactSecrets(input: string): RedactionResult {
216
- let text = input
217
- const categories: string[] = []
218
-
219
- for (const entry of SIMPLE_REPLACEMENTS) {
220
- if (!entry.pattern.test(text)) continue
221
- categories.push(entry.category)
222
- text = text.replace(entry.pattern, entry.replacement)
223
- }
224
-
225
- text = redactAssignments(text, categories)
226
- text = redactJsonLikePairs(text, categories)
227
- text = redactCliArgPairs(text, categories)
228
-
229
- // WS-7b: Optional high-entropy string redaction (opt-in via AKM_REDACT_HIGH_ENTROPY=1).
230
- if (HIGH_ENTROPY_RE && HIGH_ENTROPY_RE.test(text)) {
231
- categories.push("high_entropy")
232
- HIGH_ENTROPY_RE.lastIndex = 0 // reset after .test()
233
- text = text.replace(HIGH_ENTROPY_RE, "[REDACTED:HIGH_ENTROPY]")
234
- }
235
-
236
- // Opt-in PII redaction (AKM_REDACT_PII=1).
237
- for (const entry of PII_PATTERNS) {
238
- if (!entry.pattern.test(text)) continue
239
- categories.push(entry.category)
240
- entry.pattern.lastIndex = 0
241
- text = text.replace(entry.pattern, entry.replacement)
242
- }
243
-
244
- return {
245
- text,
246
- redacted: categories.length > 0,
247
- categories: uniq(categories),
248
- }
249
- }
250
-
251
- function normalizeObjectInput(value: unknown): unknown {
252
- if (typeof value === "string") return value
253
- if (value == null) return value
254
- if (typeof value === "number" || typeof value === "boolean") return value
255
- if (Array.isArray(value)) return value.map(normalizeObjectInput)
256
- if (typeof value === "object") {
257
- const record: Record<string, unknown> = {}
258
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
259
- record[key] = normalizeObjectInput(entry)
260
- }
261
- return record
262
- }
263
- return inspect(value, { depth: 4, breakLength: 120 })
264
- }
265
-
266
- function redactUnknown(value: unknown, categories: string[]): unknown {
267
- if (typeof value === "string") {
268
- const redacted = redactSecrets(value)
269
- categories.push(...redacted.categories)
270
- return redacted.text
271
- }
272
- if (Array.isArray(value)) return value.map((entry) => redactUnknown(entry, categories))
273
- if (value && typeof value === "object") {
274
- const output: Record<string, unknown> = {}
275
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
276
- output[key] = redactUnknown(entry, categories)
277
- }
278
- return output
279
- }
280
- return value
281
- }
282
-
283
- export function redactObject<T>(input: T): RedactedObjectResult<T> {
284
- const categories: string[] = []
285
- const normalized = normalizeObjectInput(input)
286
- const value = redactUnknown(normalized, categories) as T
287
- return {
288
- value,
289
- redacted: categories.length > 0,
290
- categories: uniq(categories),
291
- }
292
- }