pantheon-opencode 1.0.0
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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +1013 -0
- package/bin/pantheon-init.mjs +183 -0
- package/commands/pantheon-audit.md +25 -0
- package/commands/pantheon-bg.md +10 -0
- package/commands/pantheon-consolidate.md +11 -0
- package/commands/pantheon-deepwork.md +128 -0
- package/commands/pantheon-doc.md +10 -0
- package/commands/pantheon-focus.md +9 -0
- package/commands/pantheon-forget.md +58 -0
- package/commands/pantheon-hash.md +11 -0
- package/commands/pantheon-optimize.md +79 -0
- package/commands/pantheon-remember.md +44 -0
- package/commands/pantheon-search.md +48 -0
- package/commands/pantheon-status.md +71 -0
- package/commands/pantheon-todo.md +11 -0
- package/commands/pantheon.md +49 -0
- package/docs/AGENT-MCP.md +194 -0
- package/docs/ARCHITECTURE.md +384 -0
- package/docs/BRANCH-PROTECTION.md +142 -0
- package/docs/INDEX.md +81 -0
- package/docs/INSTALLATION.md +217 -0
- package/docs/MCP.md +238 -0
- package/docs/MEMORY.md +471 -0
- package/docs/MIGRATION-MEMORY-BANK.md +139 -0
- package/docs/PLATFORMS.md +5 -0
- package/docs/QUICKSTART.md +49 -0
- package/docs/README.md +18 -0
- package/docs/RELEASING.md +256 -0
- package/docs/SETUP.md +5 -0
- package/docs/UPGRADING.md +41 -0
- package/docs/mcp-recommendations.md +439 -0
- package/docs/mcp-tools.md +156 -0
- package/docs/mcp-user-guide.md +204 -0
- package/docs/persistence-mcp.md +111 -0
- package/package.json +72 -0
- package/pantheon.schema.json +158 -0
- package/scripts/__init__.py +0 -0
- package/scripts/_pantheon_paths.py +68 -0
- package/scripts/check-version-consistency.sh +23 -0
- package/scripts/code_mode_server.py +202 -0
- package/scripts/doctor.mjs +763 -0
- package/scripts/generate-prompts.sh +222 -0
- package/scripts/generate-routing-docs.mjs +104 -0
- package/scripts/hash_verify.py +192 -0
- package/scripts/init-pantheon-mcp.sh +118 -0
- package/scripts/install/agents-md.mjs +214 -0
- package/scripts/install/health-check.mjs +196 -0
- package/scripts/install/migrate.mjs +209 -0
- package/scripts/install/opencode.mjs +645 -0
- package/scripts/install/shared.mjs +655 -0
- package/scripts/install/venv.mjs +116 -0
- package/scripts/install-mcp.mjs +885 -0
- package/scripts/install.mjs +26 -0
- package/scripts/manifest.mjs +622 -0
- package/scripts/mcp_persistence_server.py +459 -0
- package/scripts/mcp_resources_server.py +206 -0
- package/scripts/memory_cache.py +78 -0
- package/scripts/memory_mcp_server.py +605 -0
- package/scripts/paths.py +64 -0
- package/scripts/prune_context.py +72 -0
- package/scripts/release-bundle.mjs +109 -0
- package/scripts/scrub-secrets.py +282 -0
- package/scripts/scrub_secrets.py +281 -0
- package/scripts/test-context-compression.sh +166 -0
- package/scripts/themis_heuristic_scan.py +287 -0
- package/scripts/todo_enforcer.py +242 -0
- package/scripts/uninstall.mjs +1057 -0
- package/scripts/validate-routing.mjs +160 -0
- package/scripts/validate_agent_frontmatter.py +226 -0
- package/scripts/versioning.mjs +254 -0
- package/skills-lock.json +16 -0
- package/src/agents/aphrodite.md +162 -0
- package/src/agents/apollo.md +109 -0
- package/src/agents/athena.md +226 -0
- package/src/agents/demeter.md +146 -0
- package/src/agents/gaia.md +82 -0
- package/src/agents/hephaestus.md +105 -0
- package/src/agents/hermes.md +302 -0
- package/src/agents/iris.md +99 -0
- package/src/agents/mnemosyne.md +226 -0
- package/src/agents/nyx.md +87 -0
- package/src/agents/prometheus.md +199 -0
- package/src/agents/talos.md +93 -0
- package/src/agents/themis.md +187 -0
- package/src/agents/zeus.md +209 -0
- package/src/instructions/agent-return-format.instructions.md +26 -0
- package/src/instructions/backend-standards.instructions.md +45 -0
- package/src/instructions/documentation-standards.instructions.md +53 -0
- package/src/instructions/frontend-standards.instructions.md +46 -0
- package/src/instructions/memory-protocol.instructions.md +67 -0
- package/src/instructions/yagni.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +72 -0
- package/src/instructions/zeus-communication-rules.instructions.md +15 -0
- package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
- package/src/mcp/_pantheon_paths.py +67 -0
- package/src/mcp/code_mode_server.py +202 -0
- package/src/mcp/init-pantheon-mcp.sh +118 -0
- package/src/mcp/install-mcp.mjs +885 -0
- package/src/mcp/mcp_persistence_server.py +458 -0
- package/src/mcp/mcp_resources_server.py +205 -0
- package/src/mcp/memory_mcp_server.py +604 -0
- package/src/mcp/requirements-mcp-core.txt +5 -0
- package/src/mcp/requirements-mcp.txt +5 -0
- package/src/plugin.ts +19 -0
- package/src/plugins/tui/dist/tui.js +143 -0
- package/src/plugins/tui/dist/tui.tsx +144 -0
- package/src/plugins/tui/package.json +32 -0
- package/src/plugins/tui/src/index.tsx +144 -0
- package/src/plugins/tui/tsdown.config.ts +22 -0
- package/src/routing.yml +499 -0
- package/src/skills/README.md +230 -0
- package/src/skills/agent-coordination/SKILL.md +95 -0
- package/src/skills/artifact-management/SKILL.md +118 -0
- package/src/skills/auto-continue/SKILL.md +280 -0
- package/src/skills/code-review-checklist/SKILL.md +139 -0
- package/src/skills/context-compression/SKILL.md +861 -0
- package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
- package/src/skills/incremental-implementation/SKILL.md +27 -0
- package/src/skills/memory-bank/SKILL.md +165 -0
- package/src/skills/orchestration-workflow/SKILL.md +311 -0
- package/src/skills/security-hardening/SKILL.md +36 -0
- package/src/skills/session-goal/SKILL.md +138 -0
- package/src/skills/spec-driven-development/SKILL.md +23 -0
- package/src/skills/tdd-with-agents/SKILL.md +170 -0
- package/src/skills/visual-review-pipeline/SKILL.md +200 -0
package/scripts/paths.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pantheon XDG Path Utilities — XDG Base Directory compliant path resolution.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from paths import xdg_config_home, opencode_plans_dir
|
|
6
|
+
|
|
7
|
+
Per the XDG Base Directory spec (XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME).
|
|
8
|
+
All fallback to their spec defaults when the env var is unset.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _from_env(var: str, default: str) -> Path:
|
|
16
|
+
value = os.environ.get(var)
|
|
17
|
+
if value is None or not value.strip():
|
|
18
|
+
value = default
|
|
19
|
+
return Path(os.path.expanduser(value.strip()))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def xdg_config_home() -> Path:
|
|
23
|
+
"""~/.config/ or $XDG_CONFIG_HOME"""
|
|
24
|
+
return _from_env("XDG_CONFIG_HOME", "~/.config")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def xdg_data_home() -> Path:
|
|
28
|
+
"""~/.local/share/ or $XDG_DATA_HOME"""
|
|
29
|
+
return _from_env("XDG_DATA_HOME", "~/.local/share")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def xdg_state_home() -> Path:
|
|
33
|
+
"""~/.local/state/ or $XDG_STATE_HOME"""
|
|
34
|
+
return _from_env("XDG_STATE_HOME", "~/.local/state")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def xdg_cache_home() -> Path:
|
|
38
|
+
"""~/.cache/ or $XDG_CACHE_HOME"""
|
|
39
|
+
return _from_env("XDG_CACHE_HOME", "~/.cache")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def opencode_config_dir() -> Path:
|
|
43
|
+
"""$XDG_CONFIG_HOME/opencode"""
|
|
44
|
+
return xdg_config_home() / "opencode"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def opencode_plans_dir() -> Path:
|
|
48
|
+
"""$XDG_CONFIG_HOME/opencode/platform/plans"""
|
|
49
|
+
return opencode_config_dir() / "platform" / "plans"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def opencode_data_dir() -> Path:
|
|
53
|
+
"""$XDG_DATA_HOME/opencode"""
|
|
54
|
+
return xdg_data_home() / "opencode"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def opencode_sync_repo_dir() -> Path:
|
|
58
|
+
"""$XDG_DATA_HOME/opencode/opencode-synced/repo"""
|
|
59
|
+
return opencode_data_dir() / "opencode-synced" / "repo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pantheon_log_dir() -> Path:
|
|
63
|
+
"""$XDG_STATE_HOME/pantheon/logs"""
|
|
64
|
+
return xdg_state_home() / "pantheon" / "logs"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""prune_context.py — Tool Output Pruning (Level 2 Compression v2).
|
|
3
|
+
Flags obsolete tool outputs in conversation history for pruning.
|
|
4
|
+
Usage: python3 scripts/prune_context.py [--dry-run] [--turns=5]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
|
|
9
|
+
HIGH_AGE_TURNS = 10
|
|
10
|
+
MID_AGE_TURNS = 5
|
|
11
|
+
HIGH_LINES = 100
|
|
12
|
+
MID_LINES = 50
|
|
13
|
+
PRUNE_THRESHOLD = 0.5
|
|
14
|
+
LOW_RELEVANCE_TYPES = ("read", "grep", "glob")
|
|
15
|
+
HIGH_RELEVANCE_TYPES = ("test", "build")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def score_output(lines: int, age_turns: int, output_type: str) -> float:
|
|
19
|
+
"""Return relevance score 0.0-1.0. Lower = better to prune."""
|
|
20
|
+
score = 1.0
|
|
21
|
+
if age_turns > HIGH_AGE_TURNS:
|
|
22
|
+
score -= 0.3
|
|
23
|
+
elif age_turns > MID_AGE_TURNS:
|
|
24
|
+
score -= 0.15
|
|
25
|
+
if lines > HIGH_LINES:
|
|
26
|
+
score -= 0.2
|
|
27
|
+
elif lines > MID_LINES:
|
|
28
|
+
score -= 0.1
|
|
29
|
+
if output_type in LOW_RELEVANCE_TYPES:
|
|
30
|
+
score -= 0.1
|
|
31
|
+
if output_type in HIGH_RELEVANCE_TYPES:
|
|
32
|
+
score += 0.1
|
|
33
|
+
return max(0.0, min(1.0, score))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
parser = argparse.ArgumentParser()
|
|
38
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
39
|
+
parser.add_argument("--turns", type=int, default=5, help="Min age in turns")
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
candidates = [
|
|
43
|
+
{"file": "src/main.py", "lines": 120, "age": 7, "type": "read"},
|
|
44
|
+
{"file": "tests/test_auth.py", "lines": 45, "age": 3, "type": "test"},
|
|
45
|
+
{"file": "package.json", "lines": 60, "age": 12, "type": "read"},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
print(f"🔍 Context Pruning Scan (age threshold: {args.turns}+ turns)")
|
|
49
|
+
print("━" * 50)
|
|
50
|
+
prunable = []
|
|
51
|
+
for c in candidates:
|
|
52
|
+
s = score_output(c["lines"], c["age"], c["type"])
|
|
53
|
+
status = "🗑️ PRUNE" if s < PRUNE_THRESHOLD else "✅ KEEP"
|
|
54
|
+
if s < PRUNE_THRESHOLD:
|
|
55
|
+
prunable.append(c)
|
|
56
|
+
print(
|
|
57
|
+
f" {status} | score: {s:.2f} | {c['file']} ({c['lines']}L, {c['age']}turns, {c['type']})"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print("━" * 50)
|
|
61
|
+
if args.dry_run:
|
|
62
|
+
print(
|
|
63
|
+
f"🔍 DRY RUN: {len(prunable)} candidates to prune (estimated ~{sum(c['lines'] for c in prunable)} lines)"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
print(
|
|
67
|
+
f"✅ {len(prunable)} outputs pruned (freed ~{sum(c['lines'] for c in prunable)} lines)"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* release-bundle.mjs — Creates the distribution tar.gz for a Pantheon release.
|
|
5
|
+
*
|
|
6
|
+
* Packages all user-relevant files into pantheon-vX.Y.Z.tar.gz.
|
|
7
|
+
* Excludes build tooling (scripts/, .github/, node_modules/).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/release-bundle.mjs # reads version from package.json
|
|
11
|
+
* node scripts/release-bundle.mjs --output ./dist # custom output dir
|
|
12
|
+
* node scripts/release-bundle.mjs --version 3.5.0 # explicit version
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'node:child_process'
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
21
|
+
const ROOT = join(__dirname, '..')
|
|
22
|
+
|
|
23
|
+
// ── Parse args ────────────────────────────────────────────────────────────────
|
|
24
|
+
const args = process.argv.slice(2)
|
|
25
|
+
function arg(flag) {
|
|
26
|
+
const i = args.indexOf(flag)
|
|
27
|
+
return i !== -1 ? args[i + 1] : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const outputDir = arg('--output') ?? join(ROOT, 'dist')
|
|
31
|
+
const version =
|
|
32
|
+
arg('--version') ?? JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version
|
|
33
|
+
|
|
34
|
+
const bundleName = `pantheon-v${version}`
|
|
35
|
+
const stagingDir = join(ROOT, 'dist', `.staging-${bundleName}`)
|
|
36
|
+
const outputTar = join(outputDir, `${bundleName}.tar.gz`)
|
|
37
|
+
|
|
38
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
function copy(src, dest) {
|
|
40
|
+
const srcPath = join(ROOT, src)
|
|
41
|
+
if (!existsSync(srcPath)) {
|
|
42
|
+
console.log(` ⏭ skipped ${src} (not found)`)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const destPath = join(stagingDir, bundleName, dest ?? src)
|
|
46
|
+
mkdirSync(dirname(destPath), { recursive: true })
|
|
47
|
+
cpSync(srcPath, destPath, { recursive: true })
|
|
48
|
+
console.log(` ✓ ${src}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Stage files ───────────────────────────────────────────────────────────────
|
|
52
|
+
console.log(`\nBuilding bundle: ${bundleName}`)
|
|
53
|
+
|
|
54
|
+
if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true })
|
|
55
|
+
mkdirSync(join(stagingDir, bundleName), { recursive: true })
|
|
56
|
+
|
|
57
|
+
// Root documents
|
|
58
|
+
copy('README.md')
|
|
59
|
+
copy('AGENTS.md')
|
|
60
|
+
copy('CHANGELOG.md')
|
|
61
|
+
copy('CONTRIBUTING.md')
|
|
62
|
+
copy('LICENSE')
|
|
63
|
+
|
|
64
|
+
// Config
|
|
65
|
+
copy('opencode.json')
|
|
66
|
+
copy('plugin.json')
|
|
67
|
+
|
|
68
|
+
// Shell helper
|
|
69
|
+
copy('sync-opencode.sh')
|
|
70
|
+
|
|
71
|
+
// Install script exposed at root (not nested under scripts/)
|
|
72
|
+
copy('scripts/install.mjs', 'install.mjs')
|
|
73
|
+
|
|
74
|
+
// Agent definitions
|
|
75
|
+
copy('agents')
|
|
76
|
+
copy('skills')
|
|
77
|
+
copy('prompts')
|
|
78
|
+
copy('platform')
|
|
79
|
+
copy('instructions')
|
|
80
|
+
copy('docs')
|
|
81
|
+
copy('images')
|
|
82
|
+
|
|
83
|
+
// Platform MCP configs
|
|
84
|
+
copy('.vscode')
|
|
85
|
+
copy('.cursor')
|
|
86
|
+
copy('.claude')
|
|
87
|
+
copy('platform/mcp/mcp-template.json')
|
|
88
|
+
|
|
89
|
+
// ── Write a minimal package.json so install.mjs can resolve it ────────────────
|
|
90
|
+
const minPkg = {
|
|
91
|
+
name: 'pantheon',
|
|
92
|
+
version,
|
|
93
|
+
description: 'Multi-agent orchestration platform for AI coding assistants',
|
|
94
|
+
type: 'module',
|
|
95
|
+
}
|
|
96
|
+
writeFileSync(join(stagingDir, bundleName, 'package.json'), `${JSON.stringify(minPkg, null, 2)}\n`)
|
|
97
|
+
|
|
98
|
+
// ── Create tar.gz ─────────────────────────────────────────────────────────────
|
|
99
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true })
|
|
100
|
+
|
|
101
|
+
execSync(`tar czf "${outputTar}" -C "${stagingDir}" "${bundleName}"`, { stdio: 'inherit' })
|
|
102
|
+
|
|
103
|
+
// ── Cleanup staging ───────────────────────────────────────────────────────────
|
|
104
|
+
rmSync(stagingDir, { recursive: true, force: true })
|
|
105
|
+
|
|
106
|
+
// ── Report ────────────────────────────────────────────────────────────────────
|
|
107
|
+
const { size } = { size: execSync(`wc -c < "${outputTar}"`).toString().trim() }
|
|
108
|
+
const kb = Math.round(Number(size) / 1024)
|
|
109
|
+
console.log(`\n✅ ${outputTar} (${kb} KB)`)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Auto-generated: resolved symlink from scrub_secrets.py
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
"""
|
|
4
|
+
scrub-secrets.py — Credential scrubber for Memory Bank promotions.
|
|
5
|
+
|
|
6
|
+
Scrubs credentials, tokens, and secrets from text before promoting it
|
|
7
|
+
to permanent memory bank files. Uses Python 3.9+ stdlib only.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/scrub-secrets.py --file path/to/file
|
|
11
|
+
# scrub in-place (with .bak backup)
|
|
12
|
+
python3 scripts/scrub-secrets.py --stdin
|
|
13
|
+
# read stdin, write scrubbed to stdout
|
|
14
|
+
python3 scripts/scrub-secrets.py --audit path/to/file
|
|
15
|
+
# only report, don't modify
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from re import Match
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Pattern definitions — order matters (private keys first to catch multi-line)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_redactor(pattern: str, replacement: str) -> tuple[re.Pattern, str]:
|
|
32
|
+
"""Compile a pattern with DOTALL for multi-line."""
|
|
33
|
+
return (re.compile(pattern, re.DOTALL), replacement)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
REDACTORS: list[tuple[re.Pattern, str]] = [
|
|
37
|
+
# SSH private keys (multi-line, before generic private key)
|
|
38
|
+
_make_redactor(
|
|
39
|
+
r"-----BEGIN\s+OPENSSH\s+PRIVATE\s+KEY-----"
|
|
40
|
+
r".*?-----END\s+OPENSSH\s+PRIVATE\s+KEY-----",
|
|
41
|
+
"[SSH PRIVATE KEY REDACTED]",
|
|
42
|
+
),
|
|
43
|
+
# Private keys (multi-line, must run before Bearer/JWT)
|
|
44
|
+
_make_redactor(
|
|
45
|
+
r"-----BEGIN\s+.*?PRIVATE\s+KEY-----"
|
|
46
|
+
r".*?-----END\s+.*?PRIVATE\s+KEY-----",
|
|
47
|
+
"[PRIVATE KEY REDACTED]",
|
|
48
|
+
),
|
|
49
|
+
# Certificates (multi-line)
|
|
50
|
+
_make_redactor(
|
|
51
|
+
r"-----BEGIN\s+CERTIFICATE-----"
|
|
52
|
+
r".*?-----END\s+CERTIFICATE-----",
|
|
53
|
+
"[CERTIFICATE REDACTED]",
|
|
54
|
+
),
|
|
55
|
+
# Bearer tokens
|
|
56
|
+
_make_redactor(
|
|
57
|
+
r"Bearer\s+[A-Za-z0-9\-._~+/]+=*",
|
|
58
|
+
"Bearer [REDACTED]",
|
|
59
|
+
),
|
|
60
|
+
# JWTs
|
|
61
|
+
_make_redactor(
|
|
62
|
+
r"eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+",
|
|
63
|
+
"[JWT REDACTED]",
|
|
64
|
+
),
|
|
65
|
+
# GitHub PATs
|
|
66
|
+
_make_redactor(
|
|
67
|
+
r"ghp_[A-Za-z0-9]{36}",
|
|
68
|
+
"[GITHUB TOKEN REDACTED]",
|
|
69
|
+
),
|
|
70
|
+
# OpenAI keys
|
|
71
|
+
_make_redactor(
|
|
72
|
+
r"sk-[A-Za-z0-9\-_]{10,}",
|
|
73
|
+
"[OPENAI KEY REDACTED]",
|
|
74
|
+
),
|
|
75
|
+
# API keys (api_key, api-key, apikey)
|
|
76
|
+
_make_redactor(
|
|
77
|
+
r"(api[_-]?key|apikey)\s*[=:]\s*['\"]?[A-Za-z0-9\-_]{8,}",
|
|
78
|
+
r"\1=[REDACTED]",
|
|
79
|
+
),
|
|
80
|
+
# Passwords / tokens / secrets
|
|
81
|
+
_make_redactor(
|
|
82
|
+
r"(token|secret|password)\s*[=:]\s*['\"]?[A-Za-z0-9\-_]{8,}",
|
|
83
|
+
r"\1=[REDACTED]",
|
|
84
|
+
),
|
|
85
|
+
# AWS Access Keys
|
|
86
|
+
_make_redactor(
|
|
87
|
+
r"AKIA[0-9A-Z]{16}",
|
|
88
|
+
"[AWS KEY REDACTED]",
|
|
89
|
+
),
|
|
90
|
+
# Google API Keys
|
|
91
|
+
_make_redactor(
|
|
92
|
+
r"AIza[0-9A-Za-z\-_]{35}",
|
|
93
|
+
"[GOOGLE API KEY REDACTED]",
|
|
94
|
+
),
|
|
95
|
+
# Slack tokens
|
|
96
|
+
_make_redactor(
|
|
97
|
+
r"xox[baprs]-[0-9a-zA-Z-]{20,}",
|
|
98
|
+
"[SLACK TOKEN REDACTED]",
|
|
99
|
+
),
|
|
100
|
+
# Heroku API Keys (UUID format)
|
|
101
|
+
_make_redactor(
|
|
102
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
103
|
+
"[HEROKU API KEY REDACTED]",
|
|
104
|
+
),
|
|
105
|
+
# Database connection strings (non-greedy to prevent over-matching)
|
|
106
|
+
_make_redactor(
|
|
107
|
+
r"postgresql\+?:\/\/.*?:.*?@",
|
|
108
|
+
"[POSTGRESQL CONNECTION REDACTED]",
|
|
109
|
+
),
|
|
110
|
+
_make_redactor(
|
|
111
|
+
r"mysql\+?:\/\/.*?:.*?@",
|
|
112
|
+
"[MYSQL CONNECTION REDACTED]",
|
|
113
|
+
),
|
|
114
|
+
_make_redactor(
|
|
115
|
+
r"redis\+?:\/\/.*?:.*?@",
|
|
116
|
+
"[REDIS CONNECTION REDACTED]",
|
|
117
|
+
),
|
|
118
|
+
# .env export statements
|
|
119
|
+
_make_redactor(
|
|
120
|
+
r"export\s+\w+=['\"]?\S+",
|
|
121
|
+
"[ENV EXPORT REDACTED]",
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# Classification dispatch: prefix → label
|
|
126
|
+
_CLASSIFIERS: list[tuple[str, str]] = [
|
|
127
|
+
("-----BEGIN OPENSSH", "ssh_private_key"),
|
|
128
|
+
("-----BEGIN CERTIFICATE", "certificate"),
|
|
129
|
+
("-----BEGIN", "private_key"),
|
|
130
|
+
("Bearer ", "bearer_token"),
|
|
131
|
+
("eyJ", "jwt"),
|
|
132
|
+
("ghp_", "github_pat"),
|
|
133
|
+
("sk-", "openai_key"),
|
|
134
|
+
("xox", "slack_token"),
|
|
135
|
+
("AKIA", "aws_key"),
|
|
136
|
+
("AIza", "google_api_key"),
|
|
137
|
+
("postgresql://", "postgresql_conn"),
|
|
138
|
+
("mysql://", "mysql_conn"),
|
|
139
|
+
("redis://", "redis_conn"),
|
|
140
|
+
("export ", "env_export"),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _classify_match(matched: str) -> str:
|
|
145
|
+
"""Return a human-readable label for a matched secret."""
|
|
146
|
+
for prefix, label in _CLASSIFIERS:
|
|
147
|
+
if matched.startswith(prefix):
|
|
148
|
+
return label
|
|
149
|
+
if re.match(r"api[_-]?key|apikey", matched, re.IGNORECASE):
|
|
150
|
+
return "api_key"
|
|
151
|
+
if re.match(r"(token|secret|password)\s*=", matched, re.IGNORECASE):
|
|
152
|
+
return "credential"
|
|
153
|
+
if re.match(
|
|
154
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
155
|
+
matched,
|
|
156
|
+
):
|
|
157
|
+
return "heroku_uuid"
|
|
158
|
+
return "unknown_secret"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def scrub(content: str) -> tuple[str, list[dict]]:
|
|
162
|
+
"""Scrub secrets from *content*.
|
|
163
|
+
|
|
164
|
+
Returns (scrubbed_content, redactions) where each redaction dict has:
|
|
165
|
+
type — human-readable label for the matched secret
|
|
166
|
+
position — (start, end) tuple of character offsets in original
|
|
167
|
+
replacement — what the secret was replaced with
|
|
168
|
+
|
|
169
|
+
Each redactor runs independently so every match in the input is
|
|
170
|
+
captured exactly once. Overlapping matches across *different* patterns
|
|
171
|
+
are possible but extremely unlikely given the pattern design.
|
|
172
|
+
"""
|
|
173
|
+
redactions: list[dict] = []
|
|
174
|
+
scrubbed = content
|
|
175
|
+
|
|
176
|
+
for pattern, replacement in REDACTORS:
|
|
177
|
+
# Walk matches in reverse offset order so replacements don't shift
|
|
178
|
+
# positions of yet-unreplaced matches.
|
|
179
|
+
matches: list[Match] = list(pattern.finditer(scrubbed))
|
|
180
|
+
for m in reversed(matches):
|
|
181
|
+
start, end = m.start(), m.end()
|
|
182
|
+
matched_text = m.group(0)
|
|
183
|
+
resolved = m.expand(replacement) if "\\" in replacement else replacement
|
|
184
|
+
|
|
185
|
+
redactions.append(
|
|
186
|
+
{
|
|
187
|
+
"type": _classify_match(matched_text),
|
|
188
|
+
"position": (start, end),
|
|
189
|
+
"replacement": resolved,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
scrubbed = scrubbed[:start] + resolved + scrubbed[end:]
|
|
193
|
+
|
|
194
|
+
# Restore original order (positional, ascending)
|
|
195
|
+
redactions.reverse()
|
|
196
|
+
return scrubbed, redactions
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# CLI
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _audit(path: str) -> None:
|
|
205
|
+
"""Print what would be redacted without modifying the file."""
|
|
206
|
+
with open(path, encoding="utf-8") as fh:
|
|
207
|
+
content = fh.read()
|
|
208
|
+
_, redactions = scrub(content)
|
|
209
|
+
if not redactions:
|
|
210
|
+
print(f"[OK] {path}: no secrets found")
|
|
211
|
+
return
|
|
212
|
+
print(f"[SCAN] {path}: {len(redactions)} potential secret(s) found:")
|
|
213
|
+
for r in redactions:
|
|
214
|
+
print(f" [{r['type']}] at {r['position']} -> {r['replacement']}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _scrub_file(path: str) -> None:
|
|
218
|
+
"""Scrub file in-place, creating a .bak backup."""
|
|
219
|
+
with open(path, encoding="utf-8") as fh:
|
|
220
|
+
content = fh.read()
|
|
221
|
+
scrubbed, redactions = scrub(content)
|
|
222
|
+
if not redactions:
|
|
223
|
+
print(f"[OK] {path}: no secrets found, file unchanged")
|
|
224
|
+
return
|
|
225
|
+
# Write backup
|
|
226
|
+
bak = path + ".bak"
|
|
227
|
+
if not os.path.exists(bak):
|
|
228
|
+
os.rename(path, bak)
|
|
229
|
+
else:
|
|
230
|
+
print(f"[WARN] {bak} already exists, skipping backup")
|
|
231
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
232
|
+
fh.write(scrubbed)
|
|
233
|
+
print(
|
|
234
|
+
f"[SECURE] {path}: {len(redactions)} secret(s) redacted "
|
|
235
|
+
f"({' + '.join(r['type'] for r in redactions)})"
|
|
236
|
+
)
|
|
237
|
+
print(f" Backup: {bak}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _scrub_stdin() -> None:
|
|
241
|
+
"""Read stdin, write scrubbed output to stdout."""
|
|
242
|
+
content = sys.stdin.read()
|
|
243
|
+
scrubbed, redactions = scrub(content)
|
|
244
|
+
for r in redactions:
|
|
245
|
+
print(f" [REDACTED {r['type']}]", file=sys.stderr)
|
|
246
|
+
sys.stdout.write(scrubbed)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main() -> None:
|
|
250
|
+
parser = argparse.ArgumentParser(
|
|
251
|
+
description="Scrub secrets from text before promoting to "
|
|
252
|
+
"permanent memory bank files.",
|
|
253
|
+
)
|
|
254
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
255
|
+
group.add_argument(
|
|
256
|
+
"--file",
|
|
257
|
+
type=str,
|
|
258
|
+
help="Scrub file in-place (with .bak backup)",
|
|
259
|
+
)
|
|
260
|
+
group.add_argument(
|
|
261
|
+
"--stdin",
|
|
262
|
+
action="store_true",
|
|
263
|
+
help="Read stdin, write scrubbed to stdout",
|
|
264
|
+
)
|
|
265
|
+
group.add_argument(
|
|
266
|
+
"--audit",
|
|
267
|
+
type=str,
|
|
268
|
+
help="Only report what would be redacted",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
|
|
273
|
+
if args.file:
|
|
274
|
+
_scrub_file(args.file)
|
|
275
|
+
elif args.stdin:
|
|
276
|
+
_scrub_stdin()
|
|
277
|
+
elif args.audit:
|
|
278
|
+
_audit(args.audit)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
main()
|