akm-opencode 0.9.9202609021827 → 0.10.261002-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.
- package/README.md +37 -16
- package/dist/index.js +3888 -0
- package/package.json +7 -8
- package/index.ts +0 -3646
- package/shared/akm-version.ts +0 -48
- package/shared/feedback-signals.ts +0 -113
- package/shared/memory-events.ts +0 -95
- package/shared/recall-policy.ts +0 -74
- package/shared/redaction.ts +0 -292
- package/shared/ref-extraction.ts +0 -120
- package/shared/state-files.ts +0 -98
- package/shared/vendor-semver.ts +0 -112
package/shared/akm-version.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/memory-events.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/recall-policy.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/redaction.ts
DELETED
|
@@ -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
|
-
}
|