@asobacloud/nehanda 0.0.0-stage → 0.3.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +444 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import ast
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ class PythonSafetyChecker:
9
+ def __init__(self, root_path):
10
+ self.root_path = root_path
11
+ self.issues = []
12
+
13
+ def check(self):
14
+ self._check_dependencies()
15
+ self._run_bandit()
16
+ self._run_ruff()
17
+ self._run_ast_checks()
18
+ self._print_summary()
19
+
20
+ def _check_dependencies(self):
21
+ try:
22
+ import importlib.util
23
+ # Check if dependencies are available (imported but unused in code)
24
+ importlib.util.find_spec('bandit')
25
+ importlib.util.find_spec('ruff')
26
+ except ImportError:
27
+ print("ERROR: Missing dependencies. Please run 'pip install -r requirements-dev.txt' to install the required tools.")
28
+ sys.exit(1)
29
+
30
+ def _run_bandit(self):
31
+ print("Running bandit...")
32
+ try:
33
+ # Exclude safety checker itself, test files, and build artifacts from Bandit checks
34
+ result = subprocess.run(
35
+ ['bandit', '-r', self.root_path,
36
+ '--exclude', 'scripts/python-safety-checker.py,tests/,*/test_*.py,.aws-sam/,build/,dist/,node_modules/,.venv/,venv/,./.venv-ci/,./.venv-configure-tests/,./_pt/,./.tvenv/,./.agent-venv/,.venv-ci/,.venv-configure-tests/,_pt/,.tvenv/,.agent-venv/'],
37
+ capture_output=True, text=True
38
+ )
39
+ # Only report High/Critical severity issues as errors (warnings are non-blocking)
40
+ if result.returncode == 1 and result.stdout:
41
+ # Parse output to find only High/Critical severity issues
42
+ lines = result.stdout.split('\n')
43
+ high_critical_issues = []
44
+ current_issue_lines = []
45
+ in_high_critical_issue = False
46
+
47
+ for i, line in enumerate(lines):
48
+ if '>> Issue:' in line:
49
+ # Start of new issue
50
+ if in_high_critical_issue and current_issue_lines:
51
+ high_critical_issues.extend(current_issue_lines)
52
+ current_issue_lines = [line]
53
+ in_high_critical_issue = False
54
+ elif 'Severity: High' in line or 'Severity: Critical' in line:
55
+ in_high_critical_issue = True
56
+ if current_issue_lines:
57
+ current_issue_lines.append(line)
58
+ elif in_high_critical_issue and current_issue_lines:
59
+ current_issue_lines.append(line)
60
+ # Stop collecting when we hit separator or next issue
61
+ if line.startswith('--') or line.strip() == '':
62
+ high_critical_issues.extend(current_issue_lines)
63
+ current_issue_lines = []
64
+ in_high_critical_issue = False
65
+
66
+ # Add last issue if applicable
67
+ if in_high_critical_issue and current_issue_lines:
68
+ high_critical_issues.extend(current_issue_lines)
69
+
70
+ if high_critical_issues:
71
+ filtered_output = '\n'.join(high_critical_issues)
72
+ self.issues.append(("Bandit", filtered_output))
73
+ except FileNotFoundError:
74
+ self.issues.append(("Bandit", "ERROR: bandit is not installed or not in PATH."))
75
+
76
+ def _run_ruff(self):
77
+ print("Running ruff...")
78
+ try:
79
+ # Exclude Jupyter notebooks from Ruff checks (they have different structure)
80
+ result = subprocess.run(
81
+ ['ruff', 'check', self.root_path, '--exclude', '*.ipynb,node_modules,.venv-ci,.tvenv,.venv-configure-tests,.agent-venv,_pt'],
82
+ capture_output=True, text=True
83
+ )
84
+ # Only add Ruff issues if exit code is non-zero (actual errors found)
85
+ if result.returncode != 0 and result.stdout:
86
+ self.issues.append(("Ruff", result.stdout))
87
+ elif result.stdout and 'All checks passed!' not in result.stdout:
88
+ # Also handle cases where ruff outputs warnings but exit code is 0
89
+ self.issues.append(("Ruff", result.stdout))
90
+ except FileNotFoundError:
91
+ self.issues.append(("Ruff", "ERROR: ruff is not installed or not in PATH."))
92
+
93
+ def _run_ast_checks(self):
94
+ print("Running AST checks...")
95
+
96
+ # Support scanning a single file directly
97
+ if os.path.isfile(self.root_path):
98
+ if self.root_path.endswith('.py'):
99
+ self._check_file_path(self.root_path)
100
+ return
101
+
102
+ for root, _, files in os.walk(self.root_path):
103
+ for file in files:
104
+ if file.endswith('.py'):
105
+ path = os.path.join(root, file)
106
+ # Skip safety checker itself, test files, and build artifacts
107
+ if ('python-safety-checker.py' in path or
108
+ '/test_' in path or
109
+ '/tests/' in path or
110
+ '/.aws-sam/' in path or
111
+ '/build/' in path or
112
+ '/dist/' in path or
113
+ '/node_modules/' in path or
114
+ '/.venv/' in path or
115
+ '/.venv-ci/' in path or
116
+ '/.tvenv/' in path or
117
+ '/.venv-configure-tests/' in path or
118
+ '/.agent-venv/' in path or
119
+ '/_pt/' in path or
120
+ '/venv/' in path):
121
+ continue
122
+ self._check_file_path(path)
123
+
124
+ def _check_file_path(self, path):
125
+ with open(path, 'r') as f:
126
+ try:
127
+ tree = ast.parse(f.read(), filename=path)
128
+ self._check_file(tree, path)
129
+ except SyntaxError as e:
130
+ self.issues.append(("AST", f"ERROR: Could not parse {path}: {e}"))
131
+
132
+ def _check_file(self, tree, path):
133
+ for node in ast.walk(tree):
134
+ if isinstance(node, ast.Assign):
135
+ for target in node.targets:
136
+ if isinstance(target, ast.Name) and target.id.upper() in ['PASSWORD', 'SECRET', 'API_KEY']:
137
+ # Check if value is from environment variable (os.getenv, os.environ, etc.)
138
+ value_is_from_env = False
139
+ if isinstance(node.value, ast.Call):
140
+ # Check for os.getenv(), os.environ.get(), etc.
141
+ if isinstance(node.value.func, ast.Attribute):
142
+ if node.value.func.attr in ['getenv', 'get'] and isinstance(node.value.func.value, ast.Name) and node.value.func.value.id == 'os':
143
+ value_is_from_env = True
144
+ # Check for os.environ['KEY'] or os.environ.get('KEY')
145
+ if isinstance(node.value.func, ast.Subscript) and isinstance(node.value.func.value, ast.Attribute):
146
+ if node.value.func.value.attr == 'environ' and isinstance(node.value.func.value.value, ast.Name) and node.value.func.value.value.id == 'os':
147
+ value_is_from_env = True
148
+ elif isinstance(node.value, ast.Attribute):
149
+ # Check for os.environ['KEY'] assignments
150
+ if isinstance(node.value.value, ast.Attribute) and node.value.value.attr == 'environ':
151
+ value_is_from_env = True
152
+
153
+ # Only flag hardcoded string literals, not environment variable lookups
154
+ # Handle both Python 3.7 (ast.Str) and Python 3.8+ (ast.Constant)
155
+ is_string_literal = False
156
+ if hasattr(ast, 'Constant'):
157
+ # Python 3.8+
158
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
159
+ is_string_literal = True
160
+ else:
161
+ # Python 3.7 and earlier
162
+ if isinstance(node.value, getattr(ast, 'Str', type(None))):
163
+ is_string_literal = True
164
+
165
+ if is_string_literal and not value_is_from_env:
166
+ self.issues.append(("AST", f"Hardcoded credential found in {path} on line {node.lineno}"))
167
+ elif isinstance(node, ast.Call):
168
+ if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
169
+ self.issues.append(("AST", f"Insecure function '{node.func.id}' used in {path} on line {node.lineno}"))
170
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ['load', 'loads'] and isinstance(node.func.value, ast.Name) and node.func.value.id == 'pickle':
171
+ self.issues.append(("AST", f"Insecure function 'pickle.{node.func.attr}' used in {path} on line {node.lineno}"))
172
+ elif isinstance(node, ast.FunctionDef):
173
+ # Missing docstrings are warnings (non-blocking), not errors
174
+ # Only flag mutable default arguments as errors
175
+ for arg in node.args.defaults:
176
+ if isinstance(arg, (ast.List, ast.Dict)):
177
+ self.issues.append(("AST", f"Mutable default argument used in function '{node.name}' in {path} on line {node.lineno}"))
178
+ elif isinstance(node, ast.ClassDef):
179
+ # Missing docstrings are warnings (non-blocking), not errors
180
+ pass
181
+
182
+ def _print_summary(self):
183
+ print("\nPython Safety Checker Summary")
184
+ print("=============================")
185
+ if not self.issues:
186
+ print("✓ All checks passed!")
187
+ else:
188
+ for tool, issue in self.issues:
189
+ print(f"\n--- {tool} Issues ---")
190
+ print(issue)
191
+ print(f"\n✗ Found {len(self.issues)} issues.")
192
+ sys.exit(1)
193
+
194
+ if __name__ == "__main__":
195
+ target = sys.argv[1] if len(sys.argv) > 1 else '.'
196
+ checker = PythonSafetyChecker(target)
197
+ checker.check()
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bash
2
+ # shell-safety-checker.sh - Safety checker for shell scripts
3
+ # Based on ui/js-safety-checker.js pattern
4
+ # Integrates shellcheck for comprehensive static analysis
5
+
6
+ set -euo pipefail
7
+
8
+ # Check if shellcheck is available
9
+ SHELLCHECK_AVAILABLE=0
10
+ if command -v shellcheck >/dev/null 2>&1; then
11
+ SHELLCHECK_AVAILABLE=1
12
+ fi
13
+
14
+ # Checks for loops that could be parallelized
15
+ # Returns warnings (non-blocking optimization suggestions) via stdout
16
+ function check_parallelization_opportunities() {
17
+ local filePath="$1"
18
+ local line_num=0
19
+ local in_loop=0
20
+ local loop_start_line=0
21
+ local loop_body=""
22
+ local loop_indent=""
23
+ local loop_type="" # To store 'for', 'while', or 'until'
24
+
25
+ # Read file line by line to handle nested structures
26
+ while IFS= read -r line; do
27
+ ((line_num++))
28
+
29
+ # Detect the start of a loop (for, while, until)
30
+ if [[ $in_loop -eq 0 && $line =~ ^([[:space:]]*)(for|while|until)[[:space:]]+.*do$ ]]; then
31
+ in_loop=1
32
+ loop_start_line=$line_num
33
+ loop_indent="${BASH_REMATCH[1]}"
34
+ loop_type="${BASH_REMATCH[2]}"
35
+ loop_body=""
36
+ continue
37
+ fi
38
+
39
+ # Detect the end of the loop
40
+ if [[ $in_loop -eq 1 && $line =~ ^${loop_indent}done$ ]]; then
41
+ # Heuristic: If the loop body is not already backgrounded, it's a candidate.
42
+ # Check for '&' at the end of a command or 'wait' for PIDs
43
+ # Note: Parallelization opportunities are optimization suggestions, not errors
44
+ if ! echo "$loop_body" | grep -qE '&[[:space:]]*$|wait[[:space:]]+\$!|pids[[:space:]]*\+='; then
45
+ echo "Potential parallelization opportunity: A '${loop_type}' loop was found at lines $loop_start_line-$line_num. If iterations are independent, consider parallelizing tasks (e.g., using '&' with 'wait', 'xargs -P', or 'parallel')."
46
+ fi
47
+
48
+ # Reset state for the next loop
49
+ in_loop=0
50
+ loop_body=""
51
+ loop_start_line=0
52
+ loop_indent=""
53
+ loop_type=""
54
+ continue
55
+ fi
56
+
57
+ # If inside a loop, append the current line to the loop_body
58
+ if [[ $in_loop -eq 1 ]]; then
59
+ loop_body+="$line\n"
60
+ fi
61
+ done < "$filePath"
62
+ }
63
+
64
+ # Add a new check for consecutive similar operations
65
+ # Returns warnings (non-blocking optimization suggestions) via stdout
66
+ function check_consecutive_similar_operations() {
67
+ local filePath="$1"
68
+ # Use awk to find consecutive lines with similar operations, ignoring common non-parallelizable keywords.
69
+ awk -v blocklist_str="export local readonly unset echo log_info log_warn log_error if for while case select function time mkdir cp git source" '
70
+ BEGIN {
71
+ # Keywords/built-ins that are typically not candidates for parallelization
72
+ split(blocklist_str, blocklist_arr, " ");
73
+ for (i in blocklist_arr) {
74
+ is_blocklisted[blocklist_arr[i]] = 1;
75
+ }
76
+ }
77
+
78
+ function check_and_report() {
79
+ if (count >= 3 && !is_blocklisted[opName]) { # Check against blocklist
80
+ print "Potential parallelization opportunity: A block of " count " similar sequential operations (\"" opName "\") was found starting at line " startLine ". Consider refactoring into a parallelized loop or using '\''xargs -P'\'' / '\''parallel'\''.";
81
+ }
82
+ }
83
+
84
+ # Match lines that look like operations, ignore empty or commented lines
85
+ /^[[:space:]]*[a-zA-Z0-9_]+[[:space:]]+.*$/ {
86
+ # Use the first word as the operation name
87
+ op = $1;
88
+ # Further refine op to ignore variable assignments or simple declarations
89
+ if (op ~ /=/) { op = ""; } # Ignore assignments
90
+ if (op == "local" || op == "export" || op == "readonly") { op = ""; } # Ignore declarations
91
+
92
+ if (op != "") {
93
+ if (op == opName && ! /&[[:space:]]*$/) {
94
+ count++;
95
+ } else {
96
+ check_and_report();
97
+ opName = op;
98
+ startLine = NR;
99
+ count = 1;
100
+ }
101
+ }
102
+ }
103
+ END { check_and_report(); }
104
+ ' "$filePath"
105
+ }
106
+
107
+
108
+ function checkShellFile() {
109
+ local filePath="$1"
110
+ local issues=()
111
+ local warnings=()
112
+
113
+ # Check parallelization opportunities (warnings, not errors)
114
+ # Capture warnings from functions that output to stdout
115
+ while IFS= read -r warning; do
116
+ warnings+=("$warning")
117
+ done < <(check_parallelization_opportunities "$filePath")
118
+
119
+ while IFS= read -r warning; do
120
+ warnings+=("$warning")
121
+ done < <(check_consecutive_similar_operations "$filePath")
122
+
123
+ # Check for silent failures (|| true) - CRITICAL
124
+ # Exception: Allow || true for non-blocking optional operations (e.g., CloudWatch logging, existence checks)
125
+ if grep -q "|| true" "$filePath"; then
126
+ # Check if it's part of a non-blocking optional operation pattern
127
+ # For multiline AWS commands, check if file has both AWS list/get/describe AND || true
128
+ local has_aws_list=0
129
+ local has_aws_get=0
130
+ local has_aws_describe=0
131
+ if grep -qE "(aws.*list|aws acm)" "$filePath"; then
132
+ has_aws_list=1
133
+ fi
134
+ if grep -qE "aws.*get" "$filePath"; then
135
+ has_aws_get=1
136
+ fi
137
+ if grep -qE "aws.*describe" "$filePath"; then
138
+ has_aws_describe=1
139
+ fi
140
+
141
+ # Check if it matches allowed patterns (same line or multiline for AWS commands)
142
+ # Allow || true for AWS list/get/describe commands (handles multiline patterns)
143
+ local is_allowed=0
144
+ if grep -qE "(2>/dev/null \|\| true|>/dev/null \|\| true|\|\| true.*2>/dev/null|send_to_cloudwatch.*\|\| true|\|\| true.*cloudwatch)" "$filePath"; then
145
+ is_allowed=1
146
+ elif [[ ($has_aws_list -eq 1 || $has_aws_get -eq 1 || $has_aws_describe -eq 1) ]]; then
147
+ is_allowed=1
148
+ fi
149
+
150
+ if [[ $is_allowed -eq 0 ]]; then
151
+ issues+=("Silent failure detected: || true")
152
+ fi
153
+ fi
154
+
155
+ # Check for missing set -euo pipefail - CRITICAL
156
+ # Check first 20 lines to allow for comments/headers
157
+ if ! head -20 "$filePath" | grep -q "set -euo pipefail"; then
158
+ issues+=("Missing 'set -euo pipefail'")
159
+ fi
160
+
161
+ # Check for dangerous combination: set -e with background jobs - CRITICAL SILENT FAILURE RISK
162
+ # Behavior-based detection rather than syntax-based
163
+ local has_background_job=0
164
+ local has_pid_capture=0
165
+ local has_wait_for_pid=0
166
+ local has_exit_code_check=0
167
+
168
+ # Check for background jobs (any command ending with &)
169
+ if grep -qE '\&[[:space:]]*$' "$filePath"; then
170
+ has_background_job=1
171
+ fi
172
+
173
+ # Check for PID capture behavior (any mechanism that stores $!)
174
+ # This includes: var=$!, array[key]=$!, var="${var}$!", etc.
175
+ if grep -qE '\$![[:space:]]*\)?[[:space:]]*$|\$![[:space:]]*\)?[[:space:]]*;|\$![[:space:]]*\)?[[:space:]]*&' "$filePath" || \
176
+ grep -qE '[a-zA-Z0-9_]+(\[[^]]+\])?[[:space:]]*=[[:space:]]*.*\$!' "$filePath"; then
177
+ has_pid_capture=1
178
+ fi
179
+
180
+ # Check for wait behavior (any wait command that could wait for specific processes)
181
+ # This includes: wait, wait $var, wait ${var}, wait $!, wait "${var}", etc.
182
+ if grep -qE 'wait[[:space:]]*$|wait[[:space:]]+[^[:space:]]+' "$filePath"; then
183
+ has_wait_for_pid=1
184
+ fi
185
+
186
+ # Check for exit code check behavior (any mechanism that checks $?)
187
+ # This includes: var=$?, if [[ $? -eq 0 ]], if [ $? = 0 ], if ! wait, wait || log, etc.
188
+ if grep -qE '\$\?[[:space:]]*\)?[[:space:]]*$|\$\?[[:space:]]*\)?[[:space:]]*;|[a-zA-Z0-9_]+[[:space:]]*=[[:space:]]*.*\$\?|if[[:space:]]+.*\$\?|\[\[[[:space:]]*.*\$\?|if[[:space:]]+!.*wait|if.*wait.*pids|wait.*pids.*then|wait[[:space:]]+.*\|\||wait[[:space:]]+\$.*\|\|' "$filePath"; then
189
+ has_exit_code_check=1
190
+ fi
191
+
192
+ if [[ $has_background_job -eq 1 ]]; then
193
+ if [[ $has_pid_capture -eq 0 || $has_wait_for_pid -eq 0 || $has_exit_code_check -eq 0 ]]; then
194
+ issues+=("CRITICAL SILENT FAILURE RISK: Background jobs ('&') with 'set -e' can fail silently. Ensure PIDs are captured, waited on, and their exit codes are explicitly checked. (Detected: BG_JOB=$has_background_job, PID_CAP=$has_pid_capture, WAIT=$has_wait_for_pid, EXIT_CHECK=$has_exit_code_check)")
195
+ fi
196
+ fi
197
+
198
+ # Check for proper shebang - CRITICAL
199
+ if ! head -1 "$filePath" | grep -q "^#!/.*bash"; then
200
+ issues+=("Missing or incorrect shebang")
201
+ fi
202
+
203
+ # Check for hardcoded credentials - SECURITY (more specific patterns)
204
+ # Exception: Variable assignments from environment variables are OK
205
+ if grep -qiE "(password|secret|api_key|api-key)\s*=\s*[a-zA-Z0-9_]{10,}" "$filePath" && ! grep -qE "\$\{.*\}.*=" "$filePath"; then
206
+ # Check if it's from an environment variable or parameter store
207
+ if ! grep -qE "(getenv|os\.environ|\$\{.*:-|\$\(.*\)|\$\{.*\}|\$\$|aws ssm|parameter.*store)" "$filePath"; then
208
+ issues+=("Potential hardcoded credential detected")
209
+ fi
210
+ fi
211
+
212
+ # Run shellcheck if available
213
+ if [[ $SHELLCHECK_AVAILABLE -eq 1 ]]; then
214
+ # Run shellcheck and capture only errors (warnings are non-blocking)
215
+ local shellcheck_output
216
+ shellcheck_output=$(shellcheck --severity=error "$filePath" 2>&1 || true)
217
+
218
+ if [[ -n "$shellcheck_output" ]]; then
219
+ issues+=("SHELLCHECK FINDINGS:")
220
+ # Parse shellcheck output and add as issues
221
+ while IFS= read -r line; do
222
+ if [[ -n "$line" ]]; then
223
+ issues+=(" $line")
224
+ fi
225
+ done <<< "$shellcheck_output"
226
+ fi
227
+ fi
228
+
229
+ # Return issues
230
+ if [[ ${#issues[@]} -gt 0 ]]; then
231
+ echo "ERROR: $filePath"
232
+ for issue in "${issues[@]}"; do
233
+ echo " - $issue"
234
+ done
235
+ # Print warnings if any (non-blocking)
236
+ if [[ ${#warnings[@]} -gt 0 ]]; then
237
+ echo " WARNINGS (non-blocking):"
238
+ for warning in "${warnings[@]}"; do
239
+ echo " - $warning"
240
+ done
241
+ fi
242
+ return 1
243
+ else
244
+ # Print warnings even if no errors
245
+ if [[ ${#warnings[@]} -gt 0 ]]; then
246
+ echo "OK: $filePath (with warnings)"
247
+ for warning in "${warnings[@]}"; do
248
+ echo " WARNING (non-blocking): $warning"
249
+ done
250
+ else
251
+ echo "OK: $filePath"
252
+ fi
253
+ return 0
254
+ fi
255
+ }
256
+
257
+ # Main execution - only run if script is executed directly
258
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
259
+ # Temporarily disable strict mode for main execution
260
+ set +euo pipefail
261
+
262
+ echo "Shell Script Safety Checker"
263
+ echo "=========================="
264
+ if [[ $SHELLCHECK_AVAILABLE -eq 1 ]]; then
265
+ echo "Using shellcheck for static analysis (warnings and errors)"
266
+ else
267
+ echo "WARNING: shellcheck not found - install with 'apt-get install shellcheck' for comprehensive analysis"
268
+ fi
269
+ echo ""
270
+
271
+ totalIssues=0
272
+ fileCount=0
273
+ targetDir="${1:-.}"
274
+
275
+ if [ -f "$targetDir" ]; then
276
+ checkShellFile "$targetDir" || totalIssues=1
277
+ fileCount=1
278
+ else
279
+ # Check all shell scripts except this safety checker
280
+ while IFS= read -r -d '' file; do
281
+ # Skip the safety checker itself
282
+ if [[ "$file" != *"shell-safety-checker.sh" ]]; then
283
+ ((fileCount++))
284
+ checkShellFile "$file" || ((totalIssues++))
285
+ fi
286
+ done < <(find "$targetDir" -name "*.sh" -type f -not -path "*/venv/*" -not -path "*/.venv*" -print0)
287
+ fi
288
+
289
+ echo ""
290
+ echo "Safety Check Summary"
291
+ echo "==================="
292
+ echo "Files checked: $fileCount"
293
+ echo "Files with issues: $totalIssues"
294
+
295
+ echo ""
296
+ echo "Final Result"
297
+ echo "============"
298
+
299
+ if [[ $totalIssues -eq 0 ]]; then
300
+ echo "✓ All checks passed!"
301
+ exit 0
302
+ else
303
+ echo "✗ Safety check failed"
304
+ exit 1
305
+ fi
306
+ fi
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { withTransaction } from './store.mjs'
4
+
5
+ const DEFAULT_EFFECTIVE = {
6
+ model_config: {
7
+ provider: 'zhipu',
8
+ model_id: 'glm_4_7_flash',
9
+ base_url: null,
10
+ },
11
+ permissions: { defaultMode: 'default', allow: [], deny: [], ask: [] },
12
+ hooks: [],
13
+ apiKeyHelper: null,
14
+ mcp_servers: {},
15
+ }
16
+
17
+ /**
18
+ * Get file modification time or null if file doesn't exist.
19
+ * @param {string} filePath - Path to the file
20
+ * @returns {Date | null} File modification time or null
21
+ */
22
+ function getFileModificationTime(filePath) {
23
+ try {
24
+ const stats = fs.statSync(filePath)
25
+ return stats.mtime
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** §4.4 — bootstrap once at process start. */
32
+ export function bootstrapSettings(db, projectRoot) {
33
+ const candidates = [
34
+ path.join(projectRoot, '.ona', 'settings.json'),
35
+ path.join(projectRoot, '.claude', 'settings.local.json'),
36
+ path.join(projectRoot, 'settings.json'),
37
+ ]
38
+
39
+ // Step 1: Load settings from files (if they exist)
40
+ let fileSettings = null
41
+ let newestFileTime = null
42
+
43
+ for (const p of candidates) {
44
+ if (!fs.existsSync(p)) continue
45
+ try {
46
+ const content = fs.readFileSync(p, 'utf8')
47
+ const parsed = JSON.parse(content)
48
+ fileSettings = deepMerge(fileSettings || {}, parsed)
49
+
50
+ // Track newest file modification time
51
+ const modTime = getFileModificationTime(p)
52
+ if (modTime && (!newestFileTime || modTime > newestFileTime)) {
53
+ newestFileTime = modTime
54
+ }
55
+ } catch {
56
+ // Skip invalid JSON files
57
+ continue
58
+ }
59
+ }
60
+
61
+ // Step 2: Load existing settings from database
62
+ const row = db.prepare(`SELECT json, updated_at FROM settings_snapshot WHERE scope = 'effective'`).get()
63
+
64
+ let dbSettings = null
65
+ let dbTimestamp = null
66
+
67
+ if (row) {
68
+ try {
69
+ dbSettings = JSON.parse(row.json)
70
+ dbTimestamp = new Date(row.updated_at)
71
+ } catch {
72
+ // Database has invalid JSON, treat as empty
73
+ dbSettings = null
74
+ }
75
+ }
76
+
77
+ // Step 3: Determine merge strategy based on timestamps
78
+ let shouldUpdateDatabase = false
79
+ let finalSettings = null
80
+ let source = null
81
+
82
+ if (!dbSettings) {
83
+ // Case A: Database is empty (first run)
84
+ // Merge: File > Defaults
85
+ finalSettings = deepMerge(structuredClone(DEFAULT_EFFECTIVE), fileSettings || {})
86
+ shouldUpdateDatabase = true
87
+ source = 'bootstrap-first-run'
88
+ } else if (fileSettings && newestFileTime && newestFileTime > dbTimestamp) {
89
+ // Case B: File was manually edited after last database update
90
+ // Merge: File > Database > Defaults
91
+ finalSettings = deepMerge(structuredClone(DEFAULT_EFFECTIVE), dbSettings)
92
+ finalSettings = deepMerge(finalSettings, fileSettings)
93
+ shouldUpdateDatabase = true
94
+ source = 'bootstrap-file-newer'
95
+ } else {
96
+ // Case C: Database has settings and is newer than (or equal to) file
97
+ // Keep database settings unchanged (preserves runtime changes)
98
+ finalSettings = dbSettings
99
+ shouldUpdateDatabase = false
100
+ source = 'bootstrap-db-preserved'
101
+ }
102
+
103
+ // Step 4: Update database if needed
104
+ if (shouldUpdateDatabase) {
105
+ const json = JSON.stringify(finalSettings)
106
+ withTransaction(db, () => {
107
+ db.prepare(
108
+ `INSERT OR REPLACE INTO settings_snapshot(scope, json, updated_at) VALUES ('effective', ?, datetime('now'))`,
109
+ ).run(json)
110
+ })
111
+ }
112
+
113
+ return finalSettings
114
+ }
115
+
116
+ export function getEffectiveSettings(db) {
117
+ const row = db.prepare(`SELECT json FROM settings_snapshot WHERE scope = 'effective'`).get()
118
+ if (!row) return structuredClone(DEFAULT_EFFECTIVE)
119
+ try { return JSON.parse(row.json) } catch { return structuredClone(DEFAULT_EFFECTIVE) }
120
+ }
121
+
122
+ export function updateEffectiveSettings(db, patch) {
123
+ const current = getEffectiveSettings(db)
124
+ const merged = deepMerge(current, patch)
125
+ const json = JSON.stringify(merged)
126
+ withTransaction(db, () => {
127
+ db.prepare(
128
+ `INSERT OR REPLACE INTO settings_snapshot(scope, json, updated_at) VALUES ('effective', ?, datetime('now'))`,
129
+ ).run(json)
130
+ })
131
+ return merged
132
+ }
133
+
134
+ function deepMerge(a, b) {
135
+ if (!b || typeof b !== 'object') return a
136
+ const out = Array.isArray(a) ? [...a] : { ...a }
137
+ for (const k of Object.keys(b)) {
138
+ const bv = b[k]
139
+ if (bv === undefined) continue
140
+ if (bv && typeof bv === 'object' && !Array.isArray(bv) && typeof out[k] === 'object' && !Array.isArray(out[k])) {
141
+ out[k] = deepMerge(out[k] || {}, bv)
142
+ } else {
143
+ out[k] = bv
144
+ }
145
+ }
146
+ return out
147
+ }