clckernel 1.2.19 → 2.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.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +23 -38
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +734 -302
  32. package/src/gitignore.js +60 -14
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -38
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -9,33 +9,14 @@ from __future__ import annotations
9
9
  import ast
10
10
  import json
11
11
  import os
12
- import subprocess
13
12
  import sys
14
- from pathlib import Path
15
13
 
16
-
17
- def _get_changed_files(target_dir: str) -> list[str] | None:
18
- """Return list of changed Python files from git diff, or None on failure."""
19
- for ref in ("HEAD~1", "--cached"):
20
- try:
21
- args = ["git", "diff", "--name-only"]
22
- if ref == "--cached":
23
- args.append("--cached")
24
- else:
25
- args.append(ref)
26
- out = subprocess.check_output(
27
- args, cwd=target_dir,
28
- stderr=subprocess.DEVNULL, text=True,
29
- )
30
- files = [
31
- f.strip() for f in out.splitlines()
32
- if f.strip().endswith('.py')
33
- ]
34
- if files:
35
- return files
36
- except (subprocess.CalledProcessError, FileNotFoundError):
37
- continue
38
- return None
14
+ # Sibling helper shipped next to the guards; works both as a script and when
15
+ # loaded by audit.py through importlib.
16
+ _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ if _TOOLS_DIR not in sys.path:
18
+ sys.path.insert(0, _TOOLS_DIR)
19
+ import _changes # noqa: E402
39
20
 
40
21
 
41
22
  class PostgresVisitor(ast.NodeVisitor):
@@ -85,7 +66,8 @@ class PostgresVisitor(ast.NodeVisitor):
85
66
 
86
67
  def visit_Call(self, node: ast.Call):
87
68
  # Check for cursor.execute(), connection.execute(), etc.
88
- if self._is_sql_method(node):
69
+ # The method name lives on the callee (node.func), not on the Call node.
70
+ if self._is_sql_method(node.func):
89
71
  # Check for raw SQL with string formatting (f-string, %, .format())
90
72
  if self._has_formatted_sql(node):
91
73
  self._add_violation(
@@ -138,8 +120,10 @@ def check(target_dir: str | None = None) -> dict:
138
120
  """
139
121
  dir_path = target_dir or os.getcwd()
140
122
 
141
- files = _get_changed_files(dir_path)
142
- if files is None:
123
+ # The snapshot being verified: the index, or HEAD when nothing is staged.
124
+ changes = _changes.changed_files(dir_path, extensions=[".py"])
125
+ mode, files = changes["mode"], changes["files"]
126
+ if mode is None:
143
127
  return {
144
128
  "exit_code": 0, "skipped": True, "name": "postgres_guard",
145
129
  "data": {"findings": [], "message": "No git diff available"},
@@ -154,17 +138,12 @@ def check(target_dir: str | None = None) -> dict:
154
138
  all_violations: list[dict] = []
155
139
 
156
140
  for filepath in files:
157
- full_path = Path(dir_path) / filepath
158
- if not full_path.exists():
159
- continue
160
-
161
- try:
162
- content = full_path.read_text(encoding="utf-8", errors="replace")
163
- except (OSError, PermissionError):
141
+ content = _changes.read_file(dir_path, filepath, mode)
142
+ if content is None:
164
143
  continue
165
144
 
166
145
  try:
167
- tree = ast.parse(content, filename=str(full_path))
146
+ tree = ast.parse(content, filename=filepath)
168
147
  except SyntaxError:
169
148
  continue
170
149
 
@@ -9,33 +9,14 @@ from __future__ import annotations
9
9
  import ast
10
10
  import json
11
11
  import os
12
- import subprocess
13
12
  import sys
14
- from pathlib import Path
15
13
 
16
-
17
- def _get_changed_files(target_dir: str) -> list[str] | None:
18
- """Return list of changed Python files from git diff, or None on failure."""
19
- for ref in ("HEAD~1", "--cached"):
20
- try:
21
- args = ["git", "diff", "--name-only"]
22
- if ref == "--cached":
23
- args.append("--cached")
24
- else:
25
- args.append(ref)
26
- out = subprocess.check_output(
27
- args, cwd=target_dir,
28
- stderr=subprocess.DEVNULL, text=True,
29
- )
30
- files = [
31
- f.strip() for f in out.splitlines()
32
- if f.strip().endswith('.py')
33
- ]
34
- if files:
35
- return files
36
- except (subprocess.CalledProcessError, FileNotFoundError):
37
- continue
38
- return None
14
+ # Sibling helper shipped next to the guards; works both as a script and when
15
+ # loaded by audit.py through importlib.
16
+ _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ if _TOOLS_DIR not in sys.path:
18
+ sys.path.insert(0, _TOOLS_DIR)
19
+ import _changes # noqa: E402
39
20
 
40
21
 
41
22
  class RedisVisitor(ast.NodeVisitor):
@@ -143,8 +124,10 @@ def check(target_dir: str | None = None) -> dict:
143
124
  """
144
125
  dir_path = target_dir or os.getcwd()
145
126
 
146
- files = _get_changed_files(dir_path)
147
- if files is None:
127
+ # The snapshot being verified: the index, or HEAD when nothing is staged.
128
+ changes = _changes.changed_files(dir_path, extensions=[".py"])
129
+ mode, files = changes["mode"], changes["files"]
130
+ if mode is None:
148
131
  return {
149
132
  "exit_code": 0, "skipped": True, "name": "redis_guard",
150
133
  "data": {"findings": [], "message": "No git diff available"},
@@ -159,17 +142,12 @@ def check(target_dir: str | None = None) -> dict:
159
142
  all_violations: list[dict] = []
160
143
 
161
144
  for filepath in files:
162
- full_path = Path(dir_path) / filepath
163
- if not full_path.exists():
164
- continue
165
-
166
- try:
167
- content = full_path.read_text(encoding="utf-8", errors="replace")
168
- except (OSError, PermissionError):
145
+ content = _changes.read_file(dir_path, filepath, mode)
146
+ if content is None:
169
147
  continue
170
148
 
171
149
  try:
172
- tree = ast.parse(content, filename=str(full_path))
150
+ tree = ast.parse(content, filename=filepath)
173
151
  except SyntaxError:
174
152
  continue
175
153
 
@@ -4,51 +4,146 @@
4
4
  * Detects hardcoded secrets, API keys, tokens, and private keys in staged/changed files.
5
5
  * Self-contained: uses only Node.js built-ins.
6
6
  *
7
+ * Rules (sdds/secret-patterns) are tried in order; the first match labels the
8
+ * line (one finding per line). They mirror RULES in scan_secrets.py exactly.
9
+ * The two generic rules skip placeholder values (see isPlaceholder):
10
+ * - Quoted Key Secret: a quoted JSON/YAML key ending in a secret word, with a
11
+ * quoted value of 8+ non-space characters holding a letter and a digit;
12
+ * - Dotenv Secret: in `.env`, `.env.*` and `*.env` files (not `.env.example`,
13
+ * `.env.sample`, `.env.template`), `NAME=value` where a `_` segment of NAME
14
+ * is a secret word and the value is not numeric-only or a boolean/null word.
15
+ *
7
16
  * @param {string} targetDir - Directory to scan (defaults to cwd)
8
17
  * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
9
18
  */
10
- const check = async function scanSecrets(targetDir) {
11
- const fs = require('fs');
12
- const path = require('path');
13
- const { execSync } = require('child_process');
19
+ const path = require('path');
20
+ const { changedFiles, readFile } = require('./_changes');
14
21
 
15
- const dir = targetDir || process.cwd();
22
+ const SECRET_KEY_WORDS = 'api[_-]?key|apikey|secret[_-]?key|api[_-]?secret|client[_-]?secret|secret|'
23
+ + 'access[_-]?token|auth[_-]?token|refresh[_-]?token|token|password|passwd|private[_-]?key';
24
+ const DOTENV_SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD|PASS|CREDENTIALS?|DSN|PRIVATE)(?:_|$)/i;
25
+ const PLACEHOLDER_WORDS = ['xxx', 'your', 'changeme', 'change_me', 'change-me', 'placeholder', 'example',
26
+ 'dummy', 'replace', 'insert', 'redacted', 'fake', 'sample', 'todo', '...', '***'];
27
+ const NON_SECRET_WORDS = ['true', 'false', 'yes', 'no', 'on', 'off', 'null', 'none'];
28
+ const TEMPLATE_SEGMENTS = ['example', 'sample', 'template'];
29
+
30
+ /** True for values that are obviously not a real secret. */
31
+ function isPlaceholder(value) {
32
+ const v = value.toLowerCase();
33
+ if (v.startsWith('$') || v.includes('${') || v.includes('$(') || v.includes('{{')) return true;
34
+ if (v.includes('<') && v.includes('>')) return true;
35
+ if (/^(.)\1*$/.test(v)) return true;
36
+ return PLACEHOLDER_WORDS.some((w) => v.includes(w));
37
+ }
38
+
39
+ /** The value of a dotenv assignment: quotes removed, or an unquoted value without its ` #` comment. */
40
+ function dotenvValue(raw) {
41
+ const quoted = /^(["'])([^\n]*?)\1/.exec(raw);
42
+ if (quoted) return quoted[2];
43
+ const comment = raw.search(/\s#/);
44
+ return (comment === -1 ? raw : raw.slice(0, comment)).trim();
45
+ }
46
+
47
+ /** `.env`, `.env.<suffix>` or `<name>.env`, unless a dot segment marks it as a template. */
48
+ function isDotenvFile(filename) {
49
+ const base = path.basename(filename || '');
50
+ if (!(base === '.env' || base.startsWith('.env.') || (base.endsWith('.env') && base.length > 4))) return false;
51
+ return !base.toLowerCase().split('.').some((seg) => TEMPLATE_SEGMENTS.includes(seg));
52
+ }
53
+
54
+ const RULES = [
55
+ { source: '-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', flags: '', label: 'Private Key' },
56
+ { source: 'AKIA[0-9A-Z]{16}', flags: '', label: 'AWS Access Key' },
57
+ { source: '(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\\s*[=:]\\s*[\'"][A-Za-z0-9_\\-]{16,}[\'"]', flags: 'i', label: 'API Key' },
58
+ { source: '(?:password|passwd|pwd)\\s*[=:]\\s*[\'"][^\'"]{6,}[\'"]', flags: 'i', label: 'Hardcoded Password' },
59
+ { source: '(?:secret|token|auth[_-]?token|bearer)\\s*[=:]\\s*[\'"][A-Za-z0-9_\\-\\.]{16,}[\'"]', flags: 'i', label: 'Secret/Token' },
60
+ { source: 'ghp_[A-Za-z0-9]{36}', flags: '', label: 'GitHub Personal Access Token' },
61
+ { source: 'sk-[A-Za-z0-9]{20,}', flags: '', label: 'OpenAI API Key' },
62
+ { source: '(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}', flags: '', label: 'Stripe API Key' },
63
+ { source: 'eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+', flags: '', label: 'JWT Token' },
64
+ { source: '(?:SUPABASE|NEXT_PUBLIC|VITE)_\\w*_KEY\\s*=\\s*[\'"][^\'"]{8,}[\'"]', flags: 'i', label: 'Framework Secret Key' },
65
+ { source: 'sk-ant-[A-Za-z0-9_-]{20,}', flags: '', label: 'Anthropic API Key' },
66
+ { source: 'sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}', flags: '', label: 'OpenAI Project Key' },
67
+ { source: 'AIza[0-9A-Za-z_-]{35}', flags: '', label: 'Google API Key' },
68
+ { source: 'xox[abprs]-[0-9]+-[0-9A-Za-z-]{8,}', flags: '', label: 'Slack Token' },
69
+ { source: 'gh[ousr]_[A-Za-z0-9]{36}', flags: '', label: 'GitHub Token' },
70
+ { source: 'github_pat_[A-Za-z0-9_]{40,}', flags: '', label: 'GitHub Fine-Grained PAT' },
71
+ {
72
+ source: `(["'])[A-Za-z0-9_.-]*(?:${SECRET_KEY_WORDS})\\1\\s*:\\s*(["'])([^"'\\s]{8,})\\2`,
73
+ flags: 'i',
74
+ label: 'Quoted Key Secret',
75
+ accept: (m) => /[A-Za-z]/.test(m[3]) && /[0-9]/.test(m[3]) && !isPlaceholder(m[3]),
76
+ },
77
+ {
78
+ source: '^\\s*(?:export\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*([^\\n]*)$',
79
+ flags: '',
80
+ label: 'Dotenv Secret',
81
+ dotenvOnly: true,
82
+ accept: (m) => {
83
+ if (!DOTENV_SECRET_NAME.test(m[1])) return false;
84
+ const value = dotenvValue(m[2].trim());
85
+ if (!value || /^[0-9]+$/.test(value) || NON_SECRET_WORDS.includes(value.toLowerCase())) return false;
86
+ return !isPlaceholder(value);
87
+ },
88
+ },
89
+ ].map((r) => ({ ...r, regex: new RegExp(r.source, r.flags) }));
90
+
91
+ const REDACTED = '…[REDACTED]';
92
+
93
+ /**
94
+ * The line with every match of every applicable rule cut to its first 4
95
+ * characters plus a marker, trimmed and capped at 120 characters. Filters are
96
+ * ignored here: over-redaction is safe. The full matched value never leaves the guard.
97
+ * @param {string} line
98
+ * @param {{ regex: RegExp }[]} rules
99
+ * @returns {string}
100
+ */
101
+ function redact(line, rules) {
102
+ let out = line;
103
+ for (const { regex } of rules) {
104
+ const global = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
105
+ out = out.replace(global, (m) => m.slice(0, 4) + REDACTED);
106
+ }
107
+ return out.trim().substring(0, 120);
108
+ }
16
109
 
17
- // Collect changed files via git, fall back to package.json check
18
- let files;
19
- try {
20
- const out = execSync('git diff --name-only HEAD~1', {
21
- cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
22
- });
23
- files = out.split('\n').filter(f => f.trim());
24
- } catch {
25
- try {
26
- const out = execSync('git diff --name-only --cached', {
27
- cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
28
- });
29
- files = out.split('\n').filter(f => f.trim());
30
- } catch {
31
- // No git history — skip gracefully
32
- return { exitCode: 0, skipped: true, name: 'scan_secrets', data: { findings: {}, message: 'No git diff available' } };
110
+ /**
111
+ * Pure matcher: the findings for `text` as the content of `filename`.
112
+ * @param {string} text
113
+ * @param {string} filename - repo-relative path; decides whether dotenv rules apply
114
+ * @returns {{ lineNo: number, col: number, label: string, snippet: string }[]}
115
+ */
116
+ function findSecrets(text, filename) {
117
+ const dotenv = isDotenvFile(filename);
118
+ const rules = RULES.filter((r) => dotenv || !r.dotenvOnly);
119
+ const findings = [];
120
+ const lines = String(text).split('\n');
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ for (const rule of rules) {
124
+ const match = rule.regex.exec(line);
125
+ if (match && (!rule.accept || rule.accept(match))) {
126
+ findings.push({ lineNo: i + 1, col: match.index, label: rule.label, snippet: redact(line, rules) });
127
+ break; // One finding per line
128
+ }
33
129
  }
34
130
  }
131
+ return findings;
132
+ }
133
+
134
+ const check = async function scanSecrets(targetDir) {
135
+ const dir = targetDir || process.cwd();
136
+
137
+ // The snapshot being verified: the index, or HEAD when nothing is staged.
138
+ const { mode, files } = changedFiles(dir);
139
+ if (mode === null) {
140
+ return { exitCode: 0, skipped: true, name: 'scan_secrets', data: { findings: {}, message: 'No git diff available' } };
141
+ }
35
142
 
36
143
  if (files.length === 0) {
37
144
  return { exitCode: 0, skipped: false, name: 'scan_secrets', data: { findings: {}, message: 'No changed files to scan' } };
38
145
  }
39
146
 
40
- const patterns = [
41
- { regex: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: 'Private Key' },
42
- { regex: /AKIA[0-9A-Z]{16}/, label: 'AWS Access Key' },
43
- { regex: /(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\s*[=:]\s*['"][A-Za-z0-9_\-]{16,}['"]/i, label: 'API Key' },
44
- { regex: /(?:password|passwd|pwd)\s*[=:]\s*['"][^'"]{6,}['"]/i, label: 'Hardcoded Password' },
45
- { regex: /(?:secret|token|auth[_-]?token|bearer)\s*[=:]\s*['"][A-Za-z0-9_\-\.]{16,}['"]/i, label: 'Secret/Token' },
46
- { regex: /ghp_[A-Za-z0-9]{36}/, label: 'GitHub Personal Access Token' },
47
- { regex: /sk-[A-Za-z0-9]{20,}/, label: 'OpenAI/Stripe API Key' },
48
- { regex: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, label: 'JWT Token' },
49
- { regex: /(?:SUPABASE|NEXT_PUBLIC|VITE)_\w*_KEY\s*=\s*['"][^'"]{8,}['"]/i, label: 'Framework Secret Key' },
50
- ];
51
-
52
147
  // Skip binary/generated files
53
148
  const skipExtensions = ['.png', '.jpg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.lock', '.min.js', '.min.css'];
54
149
 
@@ -57,41 +152,11 @@ const check = async function scanSecrets(targetDir) {
57
152
  const ext = path.extname(file);
58
153
  if (skipExtensions.includes(ext)) continue;
59
154
 
60
- const filePath = path.join(dir, file);
61
- if (!fs.existsSync(filePath)) continue;
62
-
63
- let content;
64
- try {
65
- const buffer = fs.readFileSync(filePath);
66
- // Skip binary files (check for null byte in first 512 bytes)
67
- let isBinary = false;
68
- const checkLen = Math.min(buffer.length, 512);
69
- for (let b = 0; b < checkLen; b++) {
70
- if (buffer[b] === 0) {
71
- isBinary = true;
72
- break;
73
- }
74
- }
75
- if (isBinary) continue;
76
- content = buffer.toString('utf-8');
77
- } catch {
78
- continue;
79
- }
80
-
81
- const lines = content.split('\n');
82
- const fileFindings = [];
83
-
84
- for (let i = 0; i < lines.length; i++) {
85
- const line = lines[i];
86
- for (const { regex, label } of patterns) {
87
- if (regex.test(line)) {
88
- const col = line.search(regex);
89
- fileFindings.push({ lineNo: i + 1, col, label, line: line.trim().substring(0, 120) });
90
- break; // One finding per line
91
- }
92
- }
93
- }
155
+ // Binary or missing in the snapshot reads as null.
156
+ const content = readFile(dir, file, mode);
157
+ if (content === null) continue;
94
158
 
159
+ const fileFindings = findSecrets(content, file);
95
160
  if (fileFindings.length > 0) {
96
161
  findings[file] = fileFindings;
97
162
  }
@@ -111,6 +176,8 @@ const check = async function scanSecrets(targetDir) {
111
176
  };
112
177
  };
113
178
  module.exports = check;
179
+ module.exports.findSecrets = findSecrets;
180
+ module.exports.RULES = RULES;
114
181
 
115
182
  if (require.main === module) {
116
183
  check(process.argv[2]).then(result => {