Compare commits

...

20 Commits

Author SHA1 Message Date
d3dfa41d83 Merge pull request 'fix: safe uploads, AI history continuity, deep search, sudo credentials' (#55) from fix/safe-uploads-history-search-sudo into master
Some checks failed
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 6s
Test / rust-fmt-check (push) Successful in 1m18s
Test / frontend-tests (push) Successful in 1m27s
Test / frontend-typecheck (push) Successful in 1m30s
Auto Tag / changelog (push) Failing after 1m27s
Auto Tag / build-macos-arm64 (push) Successful in 2m45s
Test / rust-clippy (push) Successful in 3m12s
Test / rust-tests (push) Successful in 5m22s
Auto Tag / build-linux-amd64 (push) Successful in 10m55s
Auto Tag / build-windows-amd64 (push) Successful in 13m53s
Auto Tag / build-linux-arm64 (push) Successful in 14m11s
Reviewed-on: #55
2026-05-31 20:52:31 +00:00
Shaun Arman
1a9c3bd65a fix(sudo): enforce username scope and singleton row in sudo_config
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m20s
Test / frontend-tests (pull_request) Successful in 1m41s
Test / frontend-typecheck (pull_request) Successful in 1m43s
Test / rust-clippy (pull_request) Successful in 3m7s
PR Review Automation / review (pull_request) Successful in 4m11s
Test / rust-tests (pull_request) Successful in 4m27s
Fixes two bugs identified in the AI code review:

1. INSERT OR REPLACE with a freshly generated UUID never matches the
   existing primary key, so it appended rows instead of replacing.
   Switch to DELETE-then-INSERT to guarantee exactly one row.

2. Username defaulted to empty string. Resolve it to the current OS
   user (USER/LOGNAME env vars, fallback 'local') so credentials are
   always bound to a specific user identity.

   test_sudo_password now passes -u <username> to sudo so the test
   runs scoped to the stored user, not an arbitrary one.

UI: show the configured username prominently in status; relabel the
field and add a scope hint below it.

Tests: test_set_sudo_singleton_delete_then_insert, three username
resolution tests.
2026-05-31 15:46:29 -05:00
Shaun Arman
26507ad3ff fix(ci): install python3 in pr-review container (ubuntu:22.04 omits it)
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m22s
Test / frontend-typecheck (pull_request) Successful in 1m33s
Test / frontend-tests (pull_request) Successful in 1m31s
Test / rust-clippy (pull_request) Successful in 3m17s
PR Review Automation / review (pull_request) Successful in 4m18s
Test / rust-tests (pull_request) Successful in 4m25s
2026-05-31 15:37:10 -05:00
Shaun Arman
0057c570ba fix(ci): write curl body to file to avoid ARG_MAX limit
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m23s
Test / frontend-tests (pull_request) Successful in 1m30s
Test / frontend-typecheck (pull_request) Successful in 1m32s
Test / rust-clippy (pull_request) Successful in 3m19s
PR Review Automation / review (pull_request) Failing after 4m19s
Test / rust-tests (pull_request) Successful in 4m40s
The 147KB JSON body was being passed as a shell argument to curl,
hitting the kernel ARG_MAX limit. Write it to /tmp/body.json via
jq redirection and use curl --data @/tmp/body.json instead.
2026-05-31 15:32:16 -05:00
Shaun Arman
84bb3a20c1 fix(ci): use printf '%s' form to avoid format strings starting with hyphen
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m33s
Test / frontend-typecheck (pull_request) Successful in 1m37s
Test / frontend-tests (pull_request) Successful in 1m36s
Test / rust-clippy (pull_request) Successful in 3m31s
PR Review Automation / review (pull_request) Failing after 3m56s
Test / rust-tests (pull_request) Successful in 4m48s
bash printf treats format strings starting with '-' as option flags in
some environments. The POSIX-safe idiom is 'printf "%s\n" content'
where the format is always "%s\n" and the content is an argument.

Applied to all prompt printf calls. Also replaced '--' in prompt text
with single '-' to eliminate any remaining double-dash ambiguity.
2026-05-31 15:27:18 -05:00
Shaun Arman
6c825b1c73 fix(ci): remove remaining printf -- calls in Analyze with LLM step
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m34s
Test / frontend-tests (pull_request) Successful in 1m31s
Test / frontend-typecheck (pull_request) Successful in 1m33s
Test / rust-clippy (pull_request) Successful in 3m13s
PR Review Automation / review (pull_request) Failing after 3m50s
Test / rust-tests (pull_request) Successful in 4m27s
2026-05-31 15:18:02 -05:00
Shaun Arman
03cda08a33 fix(ci): fix grep invalid range and printf invalid option in pr-review
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m25s
Test / frontend-typecheck (pull_request) Successful in 1m30s
Test / frontend-tests (pull_request) Successful in 1m28s
Test / rust-clippy (pull_request) Successful in 3m17s
PR Review Automation / review (pull_request) Failing after 4m12s
Test / rust-tests (pull_request) Successful in 4m30s
1. SECRET_PATTERN had [A-Za-z0-9+/_\-!@#] -- backslash-escaped hyphen
   is invalid POSIX ERE; grep parsed it as a range with invalid bounds.
   Fix: move hyphen to end of class: [A-Za-z0-9+/_!@#-].

2. printf -- '---\n' fails with 'invalid option' in bash because the
   builtin does not accept -- as end-of-options. Removed -- from all
   four printf calls.
2026-05-31 15:12:46 -05:00
Shaun Arman
3d6270fb33 fix(ci): replace heredoc with printf to fix YAML block scalar breakage
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m34s
Test / frontend-tests (pull_request) Successful in 1m33s
Test / frontend-typecheck (pull_request) Successful in 1m35s
Test / rust-clippy (pull_request) Successful in 3m10s
PR Review Automation / review (pull_request) Failing after 4m19s
Test / rust-tests (pull_request) Successful in 4m23s
Shell heredocs with unindented bodies (line 1 content) terminate YAML
run: | block scalars. The YAML parser sees the unindented heredoc body
as leaving the block, making the workflow file unparseable -- Gitea
silently stops creating runs for a workflow with invalid YAML.

Replace the single-quoted heredoc prompt with a group of printf + cat
calls. Every line stays properly indented within the YAML block scalar.
Use jq --rawfile instead of --arg to load the prompt from a temp file,
which also eliminates shell escaping hazards for large strings.
2026-05-31 15:06:09 -05:00
Shaun Arman
f8c0d247e8 fix(ci): remove concurrency group that silently dropped pr-review runs
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 2m24s
Test / frontend-typecheck (pull_request) Successful in 2m21s
Test / frontend-tests (pull_request) Successful in 2m19s
Test / rust-clippy (pull_request) Successful in 3m34s
Test / rust-tests (pull_request) Successful in 4m50s
Gitea 1.22 cancel-in-progress does not behave like GitHub Actions: when
a new synchronize event arrives while a review is running, instead of
cancelling the running job and starting a new one, it drops the new run
silently. Remove the concurrency block entirely so every commit to a PR
gets its own review run.
2026-05-31 14:59:58 -05:00
Shaun Arman
4f70fd7fb8 fix(ci): fix backtick command substitution crash in pr-review prompt
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m18s
Test / frontend-tests (pull_request) Successful in 1m29s
Test / frontend-typecheck (pull_request) Successful in 1m33s
Test / rust-clippy (pull_request) Successful in 3m16s
Test / rust-tests (pull_request) Successful in 4m33s
The PROMPT string contained backtick-quoted text for the Evidence field
example. Inside a double-quoted bash string, backticks trigger command
substitution, causing 'exact: command not found' at runtime.

Fix: build the prompt using a single-quoted heredoc (no shell expansion
inside) then splice dynamic values via sed and python3 replace() instead
of shell variable interpolation.
2026-05-31 14:53:21 -05:00
Shaun Arman
93a0c3f1ee fix(ci): add codebase index to prompt; verify findings against full repo
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m19s
Test / frontend-tests (pull_request) Successful in 1m24s
Test / frontend-typecheck (pull_request) Successful in 1m27s
Test / rust-clippy (pull_request) Successful in 3m20s
Test / rust-tests (pull_request) Successful in 4m43s
PR Review Automation / review (pull_request) Failing after 3m11s
Two changes to reduce hallucinations in pr-review:

1. Codebase index (new step "Build codebase index"):
   Generates a compact manifest of everything that EXISTS in the project:
   - All registered Tauri commands (from lib.rs generate_handler![])
   - All TypeScript exports (from tauriCommands.ts)
   - All public Rust fn signatures in commands/
   - All DB migration names
   This index is prepended to the prompt so the model cannot invent
   functions like authenticate_sudo or continue_chat_history that are
   absent from both the index and the file contents.

2. Full-repo verification (updated "Verify findings" step):
   Previously only grepped changed files, which falsely tagged findings
   about unchanged-but-real code as UNVERIFIED. Now runs git ls-files
   to load all tracked source files, so verification only fails for
   code that genuinely does not exist anywhere in the codebase.

If qwen3-coder continues to hallucinate after these changes, swap the
model name on line 184 to bedrock-personal or claude-haiku.
2026-05-31 14:48:32 -05:00
Shaun Arman
cf5bc83b75 fix(ci): add post-generation evidence verification to pr-review
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m42s
Test / frontend-typecheck (pull_request) Successful in 1m42s
Test / frontend-tests (pull_request) Successful in 1m42s
Test / rust-clippy (pull_request) Successful in 3m16s
PR Review Automation / review (pull_request) Failing after 4m33s
Test / rust-tests (pull_request) Successful in 4m54s
qwen3-coder-next fabricates plausible-looking code in its Evidence
blocks instead of quoting from the actual files provided. This adds a
Python verification step that greps each fenced code block against the
real changed files and tags any finding whose evidence cannot be found
as UNVERIFIED.

This is a safeguard, not a fix — the model is fundamentally unreliable
for grounded code review. The longer-term fix is to replace qwen3-coder
with a model that stays grounded to context (Claude Haiku, devstral,
or deepseek-coder-v2 via the LiteLLM proxy / vLLM at 172.0.1.42).
2026-05-31 14:41:47 -05:00
Shaun Arman
6373f0b09c fix(ci): fix secret scrubbing regex that was deleting legitimate code lines
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m51s
Test / frontend-tests (pull_request) Successful in 1m51s
Test / frontend-typecheck (pull_request) Successful in 1m55s
Test / rust-clippy (pull_request) Successful in 3m11s
Test / rust-tests (pull_request) Successful in 4m27s
PR Review Automation / review (pull_request) Successful in 4m47s
The previous regex matched any line containing "password", "token", etc.
near certain punctuation characters. This silently removed function
signatures, variable declarations, and test assertions from the context
sent to the LLM — causing it to hallucinate 3 BLOCKERs per review:
- "function signature missing" (the `password: &str` param was scrubbed)
- "filter body empty" (the filter condition containing "password" was scrubbed)
- "password passed unencrypted" (the decrypt_token call line was scrubbed)

Fix: match actual credential VALUES only:
- Well-known token formats (AKIA..., ghp_..., xox...)
- keyword = "long_quoted_literal" (25+ chars, clearly a value not a name)
- Standalone base64 blob lines (60+ chars, PEM-style)

Never scrub a line just because it contains a credential-related word.
2026-05-31 14:33:44 -05:00
Shaun Arman
1de59db9f0 fix(ci): rewrite pr-review to send full file contents instead of diffs
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m23s
Test / frontend-tests (pull_request) Successful in 1m29s
Test / frontend-typecheck (pull_request) Successful in 1m30s
Test / rust-clippy (pull_request) Successful in 3m14s
PR Review Automation / review (pull_request) Successful in 4m24s
Test / rust-tests (pull_request) Successful in 4m26s
2026-05-31 14:24:56 -05:00
Shaun Arman
f6787accd6 fix(agentic): inline format arg in writeln! to satisfy clippy::uninlined_format_args
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m36s
Test / frontend-typecheck (pull_request) Successful in 1m34s
Test / frontend-tests (pull_request) Successful in 1m37s
Test / rust-clippy (pull_request) Successful in 3m7s
PR Review Automation / review (pull_request) Successful in 4m24s
Test / rust-tests (pull_request) Successful in 4m22s
Rust 1.88 enforces clippy::uninlined_format_args as a style lint under
-D warnings. Change `writeln!(stdin, "{}", password)` to the inline
form `writeln!(stdin, "{password}")`.
2026-05-31 14:19:29 -05:00
Shaun Arman
06956940e2 fix(ci): reduce AI review hallucinations in pr-review workflow
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m46s
Test / frontend-typecheck (pull_request) Successful in 1m49s
Test / frontend-tests (pull_request) Successful in 1m46s
Test / rust-clippy (pull_request) Failing after 3m12s
PR Review Automation / review (pull_request) Successful in 4m37s
Test / rust-tests (pull_request) Successful in 4m34s
Three changes:
- Exclude Cargo.lock/lockfiles from the diff — removes ~163 lines of
  hash noise that waste the review budget with no value
- Raise line cap from 500 to 3000 and add a truncation notice when
  the diff is cut, so the model knows the diff is incomplete
- Harden prompt: require quoted evidence for every finding; add explicit
  self-verification step for missing-identifier claims (search full diff
  before raising); tighten no-hallucinate instruction
2026-05-31 14:08:10 -05:00
Shaun Arman
cf1d5adb83 docs(analysis): document zip-slip safety guarantee in extract_docx_text
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 1m35s
Test / frontend-typecheck (pull_request) Successful in 2m16s
Test / frontend-tests (pull_request) Successful in 2m13s
Test / rust-clippy (pull_request) Failing after 3m43s
PR Review Automation / review (pull_request) Successful in 4m11s
Test / rust-tests (pull_request) Successful in 4m59s
Only a single hardcoded entry (word/document.xml) is ever accessed from
the ZIP archive; no arbitrary path extraction occurs, so path traversal
attacks cannot apply. Add a comment to make this invariant explicit for
future maintainers.
2026-05-31 13:57:38 -05:00
Shaun Arman
ed2e25f835 chore: update Cargo.lock for lopdf, zip, quick-xml deps 2026-05-31 13:51:08 -05:00
Shaun Arman
f47ec90d05 feat(upload): add safe file extension validation and binary text extraction
- Add extension allowlist (SAFE_TEXT_EXTENSIONS + SAFE_BINARY_EXTENSIONS)
  rejecting unsupported file types at both upload_log_file and
  upload_log_file_by_content entry points
- Add extract_text_content() with PDF text extraction via lopdf and
  DOCX extraction via zip+quick-xml
- Binary files (PDF/DOCX) get extracted text written to .extracted.txt
  for downstream PII detection
- Expand frontend file input accept list and add collapsible
  supported-formats disclosure element
- Add 11 unit tests covering allowlist logic and extraction paths
2026-05-31 13:50:59 -05:00
Shaun Arman
cd67a09a6a fix(ai,search): load history across all conversations; deep search related tables
AI history continuity: Changed the history-load query in chat_message to
JOIN ai_conversations and select by issue_id instead of single conversation_id.
This preserves full context when provider/model changes mid-triage.

Deep search: Added DISTINCT to list_issues SELECT and extended the search
filter with EXISTS subqueries covering ai_messages, resolution_steps,
log_files, and timeline_events. Ensures comprehensive search without
duplicate results.

Includes 11 new unit tests covering both features.
2026-05-31 13:50:29 -05:00
14 changed files with 1574 additions and 53 deletions

View File

@ -4,9 +4,6 @@ on:
pull_request:
types: [opened, synchronize, reopened, edited]
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
@ -21,7 +18,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
apt-get update -qq && apt-get install -y -qq git curl jq
apt-get update -qq && apt-get install -y -qq git curl jq python3
- name: Checkout code
shell: bash
@ -34,18 +31,114 @@ jobs:
git fetch --depth=1 origin ${{ github.head_ref }}
git checkout FETCH_HEAD
- name: Get PR diff
id: diff
- name: Build review context
id: context
shell: bash
run: |
set -euo pipefail
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}..HEAD > /tmp/pr_diff.txt
echo "diff_size=$(wc -l < /tmp/pr_diff.txt | tr -d ' ')" >> $GITHUB_OUTPUT
# List changed source files (exclude generated/lock files)
git diff --name-only origin/${{ github.base_ref }}..HEAD \
-- ':!Cargo.lock' ':!package-lock.json' ':!*.lock' \
> /tmp/pr_files.txt
FILE_COUNT=$(wc -l < /tmp/pr_files.txt | tr -d ' ')
echo "files_changed=${FILE_COUNT}" >> $GITHUB_OUTPUT
if [ "$FILE_COUNT" -eq 0 ]; then
echo "No reviewable files changed."
echo "diff_size=0" >> $GITHUB_OUTPUT
exit 0
fi
# Build context: full file content for each changed file.
# Files <= 500 lines: include complete content.
# Files > 500 lines: include the per-file diff with generous context (±50 lines).
#
# Secret scrubbing: match actual credential VALUES only — known API key formats,
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
# which would silently delete function signatures, variable declarations, and tests.
SECRET_PATTERN='AKIA[A-Z0-9]{16}|gh[opsu]_[A-Za-z0-9_]{36,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|(password|token|api_key|secret)[[:space:]]*=[[:space:]]*["'"'"'][A-Za-z0-9+/_!@#-]{25,}["'"'"']'
# Only strip lines that are ENTIRELY a long base64 blob (e.g. PEM cert bodies)
B64_PATTERN='^[[:space:]]*[A-Za-z0-9+/]{60,}={0,2}[[:space:]]*$'
> /tmp/pr_context.txt
while IFS= read -r file; do
[ -f "$file" ] || continue
lines=$(wc -l < "$file" | tr -d ' ')
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$lines" >> /tmp/pr_context.txt
if [ "$lines" -le 500 ]; then
# Full file — model sees the complete implementation
grep -v -E "$SECRET_PATTERN" "$file" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
else
# Large file — emit annotated diff hunks (±50 lines of context each)
printf '[File too large for full view (%s lines) — showing changed sections only]\n' "$lines" >> /tmp/pr_context.txt
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
| grep -v -E "$SECRET_PATTERN" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
fi
done < /tmp/pr_files.txt
TOTAL=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
echo "diff_size=${TOTAL}" >> $GITHUB_OUTPUT
# Cap at 6000 lines so we stay within the model's context window
if [ "$TOTAL" -gt 6000 ]; then
head -n 6000 /tmp/pr_context.txt > /tmp/pr_context_capped.txt
mv /tmp/pr_context_capped.txt /tmp/pr_context.txt
echo "[CONTEXT TRUNCATED at 6000 lines — ${TOTAL} total]" >> /tmp/pr_context.txt
fi
- name: Build codebase index
id: index
if: steps.context.outputs.diff_size != '0'
shell: bash
run: |
set -euo pipefail
# Build a compact index of everything that EXISTS in this codebase.
# Included in the prompt so the model cannot invent functions/commands/tables
# that are not present — any finding referencing something absent from this
# index is immediately suspect.
{
echo "## CODEBASE INDEX"
echo "These are the ONLY Tauri commands, TypeScript exports, Rust public functions,"
echo "and database tables that exist in this project. Before raising any finding,"
echo "confirm that every symbol you cite appears in this list or in the file"
echo "contents below. If it does not appear in either, your finding is fabricated."
echo ""
echo "### Registered Tauri commands (lib.rs generate_handler![]):"
grep -oE 'commands::[a-z_]+::[a-z_]+' src-tauri/src/lib.rs 2>/dev/null \
| sort -u | sed 's/^/ /' || true
echo ""
echo "### TypeScript invoke wrappers (src/lib/tauriCommands.ts):"
grep -E '^export (const|interface|type) ' src/lib/tauriCommands.ts 2>/dev/null \
| sed 's/^/ /' || true
echo ""
echo "### Public Rust functions in src-tauri/src/commands/:"
grep -rh --include='*.rs' '^pub ' src-tauri/src/commands/ 2>/dev/null \
| grep 'fn ' | sed 's/^/ /' | sort || true
echo ""
echo "### Database tables (src-tauri/src/db/migrations.rs):"
grep -oE '"[0-9]+_[a-z_]+"' src-tauri/src/db/migrations.rs 2>/dev/null \
| tr -d '"' | sed 's/^/ /' || true
echo ""
} > /tmp/codebase_index.txt
INDEX_LINES=$(wc -l < /tmp/codebase_index.txt | tr -d ' ')
echo "index_lines=${INDEX_LINES}" >> $GITHUB_OUTPUT
echo "Built codebase index: ${INDEX_LINES} lines"
- name: Analyze with LLM
id: analyze
if: steps.diff.outputs.diff_size != '0'
if: steps.context.outputs.diff_size != '0'
shell: bash
env:
LITELLM_URL: http://172.0.0.29:11434/v1
@ -54,25 +147,62 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if grep -q "^Binary files" /tmp/pr_diff.txt; then
echo "WARNING: Binary file changes detected — they will be excluded from analysis"
fi
DIFF_CONTENT=$(head -n 500 /tmp/pr_diff.txt \
| grep -v -E '^[+-].*(password[[:space:]]*[=:"'"'"']|token[[:space:]]*[=:"'"'"']|secret[[:space:]]*[=:"'"'"']|api_key[[:space:]]*[=:"'"'"']|private_key[[:space:]]*[=:"'"'"']|Authorization:[[:space:]]|AKIA[A-Z0-9]{16}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|gh[opsu]_[A-Za-z0-9_]{36,}|https?://[^@[:space:]]+:[^@[:space:]]+@)' \
| grep -v -E '^[+-].*[A-Za-z0-9+/]{40,}={0,2}([^A-Za-z0-9+/=]|$)')
PROMPT="You are a senior engineer performing a focused code review. Your review must be grounded strictly in the diff provided — do not invent issues about code you cannot see.\n\nPR Title: ${PR_TITLE}\n\nDiff:\n${DIFF_CONTENT}\n\n## Instructions\n\n1. **Read the entire diff first.** Before raising any issue, verify it against the actual lines in the diff. If something appears to be missing, confirm it is absent from ALL relevant files in the diff before claiming it is missing.\n\n2. **Quote the evidence.** For every issue you raise, cite the specific file and line from the diff that supports your claim. If you cannot quote a line, do not raise the issue.\n\n3. **Distinguish severity clearly:**\n - BLOCKER: broken right now, will cause crashes or data loss\n - WARNING: works but has a real risk that should be addressed before merge\n - SUGGESTION: improvement worth considering in a follow-up PR\n\n4. **Do not raise issues about code outside the diff.** If a concern requires reading files not present in the diff, say 'outside the scope of this diff' and skip it.\n\n5. **Keep it concise.** Lead with a one-paragraph summary, then list only genuine findings with evidence. Avoid restating what the code already does correctly unless it is directly relevant to a finding.\n\n## Output format\n\n**Summary** (1 paragraph)\n\n**Findings** (only real issues with quoted evidence)\n- [BLOCKER/WARNING/SUGGESTION] filename:line — description and suggested fix\n\n**Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES"
BODY=$(jq -cn \
CHANGED_FILES=$(tr '\n' ' ' < /tmp/pr_files.txt)
# Build prompt file. Use 'printf "%s\n" text' throughout so the format
# string is always "%s\n" and content with leading hyphens or embedded
# double-dashes is never misinterpreted as a printf option flag.
{
printf '%s\n\n' 'You are a senior engineer performing a code review.'
printf 'PR Title: %s\n' "$PR_TITLE"
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
printf '%s\n' '---'
cat /tmp/codebase_index.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## Changed file contents'
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
printf '%s\n' '---'
cat /tmp/pr_context.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## Instructions'
printf '%s\n' 'Before raising any finding:'
printf '%s\n' '1. Confirm every symbol you cite exists in the CODEBASE INDEX or file'
printf '%s\n' ' contents above. If absent from both, discard the finding.'
printf '%s\n' '2. Quote the exact line(s) from the file contents that support it.'
printf '%s\n' '3. Confirm the issue is genuine, not intentional design.'
printf '%s\n\n' '4. If any step fails, discard silently - do not mention it.'
printf '%s\n\n' 'Do NOT show reasoning. Only output confirmed issues.'
printf '%s\n' 'Severity:'
printf '%s\n' '- BLOCKER: fails to compile, corrupts data, or security vulnerability'
printf '%s\n' '- WARNING: real risk to address before merge'
printf '%s\n\n' '- SUGGESTION: minor improvement, follow-up PR fine'
printf '%s\n\n' 'Focus: security bugs, logic errors, data loss, injection, unhandled errors.'
printf '%s\n\n' 'Ignore: style, missing comments, speculative future concerns.'
printf '%s\n\n' '## Output format (strict)'
printf '%s\n\n' '**Summary** (2-3 sentences)'
printf '%s\n' '**Findings**'
printf '%s\n' '- [SEVERITY] file:line - description'
printf '%s\n' ' Evidence: quoted line'
printf '%s\n\n' ' Fix: concrete change'
printf '%s\n\n' '(Write "No findings." if none.)'
printf '%s\n' '**Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
} > /tmp/prompt.txt
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
jq -cn \
--arg model "qwen3-coder-next" \
--arg content "$PROMPT" \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}')
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API (${#BODY} bytes)..."
--rawfile content /tmp/prompt.txt \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
> /tmp/body.json
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API ($(wc -c < /tmp/body.json) bytes)..."
HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
-o /tmp/llm_response.json -w "%{http_code}" \
-X POST "$LITELLM_URL/chat/completions" \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \
-d "$BODY")
--data @/tmp/body.json)
echo "HTTP status: $HTTP_CODE"
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
if [ "$HTTP_CODE" != "200" ]; then
@ -93,8 +223,77 @@ jobs:
echo "Review length: ${#REVIEW} chars"
echo "$REVIEW" > /tmp/pr_review.txt
- name: Verify findings against codebase
if: steps.analyze.outcome == 'success'
shell: bash
run: |
set -euo pipefail
# For each finding that contains a fenced code block under "Evidence:",
# grep at least one substantial line of that block against the FULL repository.
# Searching the full repo (not just changed files) prevents false UNVERIFIED
# tags when the model correctly quotes unchanged files, while still flagging
# fabricated code that doesn't exist anywhere in the codebase.
python3 - << 'PYEOF'
import re, os, subprocess
review = open('/tmp/pr_review.txt').read()
# Load ENTIRE tracked repository (all .rs, .ts, .tsx, .yml, .toml, .json files)
result = subprocess.run(
['git', 'ls-files', '--',
'*.rs', '*.ts', '*.tsx', '*.yml', '*.yaml', '*.toml', '*.json', '*.sql'],
capture_output=True, text=True
)
all_tracked = [f.strip() for f in result.stdout.splitlines() if f.strip()]
all_content_parts = []
for path in all_tracked:
if os.path.isfile(path):
try:
all_content_parts.append(open(path).read())
except Exception:
pass
all_content = '\n'.join(all_content_parts)
def evidence_exists(block: str) -> bool:
"""True if ≥1 significant line from the block is found verbatim in changed files."""
for raw in block.splitlines():
line = raw.lstrip('+-').strip()
# Skip blank, very short, pure-comment, or diff-header lines
if len(line) < 20:
continue
if line.startswith(('//','#','/*','*','Fix:','Evidence:','---','+++')):
continue
if line in all_content:
return True
return False
# Split on finding markers; re-join after optional tagging
severity_re = re.compile(r'\[(BLOCKER|WARNING|SUGGESTION)\]')
def tag_if_unverified(finding_text: str) -> str:
code_match = re.search(r'```[^\n]*\n(.*?)```', finding_text, re.DOTALL)
if code_match and not evidence_exists(code_match.group(1)):
# Replace first severity tag with a prefixed version
return severity_re.sub(
lambda m: f'[{m.group(1)} — ⚠️ UNVERIFIED: evidence not found in PR files]',
finding_text, count=1
)
return finding_text
# Split review into preamble + individual finding blocks
# Each block starts at a severity marker line
parts = re.split(r'(?=^\[(?:BLOCKER|WARNING|SUGGESTION)\])', review, flags=re.MULTILINE)
result = parts[0] # preamble (Summary, etc.)
for block in parts[1:]:
result += tag_if_unverified(block)
open('/tmp/pr_review.txt', 'w').write(result)
print(f"Verification complete — {len(parts)-1} finding(s) checked.")
PYEOF
- name: Post review comment
if: always() && steps.diff.outputs.diff_size != '0'
if: always() && steps.context.outputs.diff_size != '0'
shell: bash
env:
TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }}
@ -109,7 +308,7 @@ jobs:
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
BODY=$(jq -n \
--arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}\n\n---\n*automated code review*" \
--arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \
'{body: $body, event: "COMMENT"}')
else
BODY=$(jq -n \
@ -131,4 +330,4 @@ jobs:
- name: Cleanup
if: always()
shell: bash
run: rm -f /tmp/pr_diff.txt /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json
run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt

163
src-tauri/Cargo.lock generated
View File

@ -355,6 +355,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@ -429,6 +449,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -708,6 +730,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -1188,6 +1229,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@ -2467,7 +2514,7 @@ dependencies = [
"hmac",
"iterator-sorted",
"k256",
"pbkdf2",
"pbkdf2 0.12.2",
"rand 0.8.5",
"scrypt",
"serde",
@ -2588,6 +2635,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@ -2818,13 +2875,16 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
dependencies = [
"chrono",
"encoding_rs",
"flate2",
"itoa",
"linked-hash-map",
"log",
"md5",
"nom",
"pom",
"rayon",
"time",
"weezl",
]
@ -2938,6 +2998,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
@ -3122,6 +3188,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -3442,6 +3518,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -3460,6 +3547,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"hmac",
"password-hash",
"sha2",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@ -4161,6 +4260,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -4650,7 +4769,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"pbkdf2 0.12.2",
"salsa20",
"sha2",
]
@ -6252,8 +6371,10 @@ dependencies = [
"hex",
"infer 0.15.0",
"lazy_static",
"lopdf",
"mockito",
"printpdf",
"quick-xml 0.36.2",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
@ -6278,6 +6399,7 @@ dependencies = [
"urlencoding",
"uuid",
"warp",
"zip 0.6.6",
]
[[package]]
@ -7804,10 +7926,18 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"aes",
"byteorder",
"bzip2",
"constant_time_eq 0.1.5",
"crc32fast",
"crossbeam-utils",
"flate2",
"hmac",
"pbkdf2 0.11.0",
"sha1",
"time",
"zstd",
]
[[package]]
@ -7848,6 +7978,35 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "5.0.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
dependencies = [
"libc",
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"

View File

@ -45,6 +45,9 @@ warp = "0.3"
urlencoding = "2"
infer = "0.15"
url = "2.5.8"
lopdf = "0.31"
zip = "0.6"
quick-xml = "0.36"
rmcp = { version = "1.7.0", features = [
"client",
"transport-child-process",

View File

@ -0,0 +1,123 @@
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Debug, serde::Serialize)]
pub struct SudoOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
pub exit_code: Option<i32>,
}
/// Execute a command via sudo, passing the password via stdin (never via cmdline args).
/// `args` must NOT include "sudo" — pass only the target command and its arguments.
pub fn run_sudo_command(password: &str, args: &[&str]) -> Result<SudoOutput, String> {
let mut child = Command::new("sudo")
.arg("-S") // read password from stdin
.arg("-p")
.arg("") // suppress prompt text
.arg("--") // end of sudo options — prevents injection
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn sudo: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
writeln!(stdin, "{password}")
.map_err(|e| format!("Failed to write password to stdin: {e}"))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for sudo: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = strip_sudo_password_prompt(&String::from_utf8_lossy(&output.stderr));
Ok(SudoOutput {
stdout,
stderr,
success: output.status.success(),
exit_code: output.status.code(),
})
}
/// Strip "[sudo] password for ..." prompt lines from stderr before logging.
fn strip_sudo_password_prompt(text: &str) -> String {
text.lines()
.filter(|line| {
let lower = line.to_lowercase();
!lower.contains("[sudo] password") && !lower.starts_with("password:")
})
.collect::<Vec<_>>()
.join("\n")
}
/// Like run_sudo_command but writes a sanitized audit entry first.
/// The password is NEVER included in audit details.
pub fn run_sudo_command_audited(
password: &str,
args: &[&str],
db: &rusqlite::Connection,
) -> Result<SudoOutput, String> {
let sanitized_args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let details = serde_json::json!({
"command": sanitized_args,
"note": "password delivered via stdin pipe only — never logged"
});
crate::audit::log::write_audit_event(
db,
"sudo_command",
"system",
"local",
&details.to_string(),
)
.map_err(|e| format!("Audit log failed: {e}"))?;
run_sudo_command(password, args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_sudo_password_prompt_removes_prompt_lines() {
let stderr = "[sudo] password for alice:\nsome other output\nPassword: bad line";
let cleaned = strip_sudo_password_prompt(stderr);
assert!(!cleaned.contains("[sudo] password"));
assert!(!cleaned.contains("Password:"));
assert!(cleaned.contains("some other output"));
}
#[test]
fn test_strip_sudo_password_prompt_keeps_clean_output() {
let stderr = "Error: permission denied\nsome warning";
let cleaned = strip_sudo_password_prompt(stderr);
assert_eq!(cleaned, "Error: permission denied\nsome warning");
}
#[test]
fn test_run_sudo_command_audited_does_not_log_password() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let _result = run_sudo_command_audited("my-secret-password", &["true"], &conn);
// result may fail in test environment, but audit log must exist
let details: String = conn
.query_row(
"SELECT details FROM audit_log WHERE action = 'sudo_command' LIMIT 1",
[],
|row| row.get(0),
)
.unwrap_or_default();
assert!(
!details.contains("my-secret-password"),
"Password must never appear in audit log"
);
assert!(details.contains("true"), "Command args should be logged");
}
}

View File

@ -204,15 +204,19 @@ pub async fn chat_message(
}
};
// Load conversation history (use and_then to keep stmt lifetime within closure)
// Load conversation history across ALL conversations for this issue
let history: Vec<Message> = {
let db = state.db.lock().map_err(|e| e.to_string())?;
let raw: Vec<(String, String)> = db
.prepare(
"SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&conversation_id], |row| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
@ -1025,4 +1029,194 @@ mod tests {
let list = extract_list(text, "KEY_FINDINGS:");
assert_eq!(list, vec!["Actual item"]);
}
#[test]
fn test_history_query_same_conversation() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0], ("user".to_string(), "Hello".to_string()));
assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string()));
}
#[test]
fn test_history_query_across_conversations() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "From conv 1");
assert_eq!(raw[1].1, "From conv 2");
}
#[test]
fn test_history_query_empty_for_new_issue() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-new", "Empty", &now, &now],
)
.unwrap();
let issue_id = "issue-new";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert!(raw.is_empty());
}
#[test]
fn test_history_query_ordered_by_created_at() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
// Insert messages out of order: conv-2 message is earlier than conv-1 message
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "First");
assert_eq!(raw[1].1, "Second");
}
}

View File

@ -9,6 +9,141 @@ use crate::state::AppState;
const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024;
const SAFE_TEXT_EXTENSIONS: &[&str] = &[
"log",
"txt",
"out",
"err",
"syslog",
"journal",
"yaml",
"yml",
"json",
"toml",
"xml",
"ini",
"cfg",
"conf",
"config",
"env",
"properties",
"md",
"markdown",
"rst",
"csv",
"tsv",
"ndjson",
"jsonl",
"sql",
"sh",
"bash",
"zsh",
"py",
"js",
"ts",
"rb",
"go",
"rs",
"java",
"html",
"htm",
"css",
"diff",
"patch",
"rtf",
];
const SAFE_BINARY_EXTENSIONS: &[&str] = &["pdf", "docx", "doc", "xlsx", "xls"];
pub fn is_safe_file(path: &Path) -> bool {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
match ext.as_deref() {
Some(e) => SAFE_TEXT_EXTENSIONS.contains(&e) || SAFE_BINARY_EXTENSIONS.contains(&e),
None => false,
}
}
pub fn extract_text_content(path: &Path) -> Result<String, String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"pdf" => extract_pdf_text(path),
"docx" | "doc" => extract_docx_text(path),
"xlsx" | "xls" => Err(format!(
"Spreadsheet format .{ext} is not yet supported for text extraction. \
Export the sheet as CSV and upload that instead."
)),
_ => std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}")),
}
}
fn extract_pdf_text(path: &Path) -> Result<String, String> {
let doc = lopdf::Document::load(path).map_err(|e| format!("Failed to parse PDF: {e}"))?;
let mut text = String::new();
let mut pages: Vec<u32> = doc.get_pages().keys().copied().collect();
pages.sort_unstable();
for page_num in pages {
if let Ok(content) = doc.extract_text(&[page_num]) {
text.push_str(&content);
text.push('\n');
}
}
if text.trim().is_empty() {
return Err("PDF contains no extractable text (may be a scanned image)".to_string());
}
Ok(text)
}
fn extract_docx_text(path: &Path) -> Result<String, String> {
use std::io::Read as _;
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {e}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Failed to open as ZIP/DOCX: {e}"))?;
let mut xml_content = String::new();
{
// Safety: only one hardcoded entry is ever accessed; no arbitrary path extraction is
// performed, so zip-slip path traversal attacks cannot apply here.
let mut doc_xml = archive
.by_name("word/document.xml")
.map_err(|_| "Not a valid DOCX: missing word/document.xml".to_string())?;
doc_xml
.read_to_string(&mut xml_content)
.map_err(|e| format!("Failed to read document.xml: {e}"))?;
}
let mut text = String::new();
let mut reader = quick_xml::Reader::from_str(&xml_content);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(quick_xml::events::Event::Text(e)) => {
if let Ok(s) = e.unescape() {
let trimmed = s.trim().to_string();
if !trimmed.is_empty() {
text.push_str(&trimmed);
text.push(' ');
}
}
}
Ok(quick_xml::events::Event::Eof) => break,
Err(e) => return Err(format!("XML parse error: {e}")),
_ => {}
}
buf.clear();
}
if text.trim().is_empty() {
return Err("DOCX contains no extractable text".to_string());
}
Ok(text)
}
fn validate_log_file_path(file_path: &str) -> Result<PathBuf, String> {
let path = Path::new(file_path);
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
@ -35,24 +170,59 @@ pub async fn upload_log_file(
state: State<'_, AppState>,
) -> Result<LogFile, String> {
let canonical_path = validate_log_file_path(&file_path)?;
let content = std::fs::read(&canonical_path).map_err(|_| "Failed to read selected log file")?;
let content_hash = format!("{:x}", Sha256::digest(&content));
if !is_safe_file(&canonical_path) {
let ext = canonical_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)");
return Err(format!(
"File type '.{ext}' is not supported. Supported formats include .log, .txt, .json, .pdf, .docx, .md, and many more."
));
}
let file_name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let file_size = content.len() as i64;
let mime_type = if file_name.ends_with(".json") {
"application/json"
} else if file_name.ends_with(".xml") {
"application/xml"
} else {
"text/plain"
let file_ext = canonical_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
let extracted_text = extract_text_content(&canonical_path)
.map_err(|e| format!("Failed to read file content: {e}"))?;
let content_bytes = extracted_text.as_bytes();
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
let file_size = content_bytes.len() as i64;
let mime_type = match file_ext.as_str() {
"json" => "application/json",
"xml" => "application/xml",
"yaml" | "yml" => "application/yaml",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"md" | "markdown" => "text/markdown",
"csv" | "tsv" => "text/csv",
"html" | "htm" => "text/html",
_ => "text/plain",
};
let canonical_file_path = canonical_path.to_string_lossy().to_string();
let log_file = LogFile::new(issue_id.clone(), file_name, canonical_file_path, file_size);
let is_binary = SAFE_BINARY_EXTENSIONS.contains(&file_ext.as_str());
let stored_path = if is_binary {
let extracted_path = canonical_path.with_extension("extracted.txt");
std::fs::write(&extracted_path, &extracted_text)
.map_err(|e| format!("Failed to write extracted text: {e}"))?;
extracted_path.to_string_lossy().to_string()
} else {
canonical_path.to_string_lossy().to_string()
};
let log_file = LogFile::new(issue_id.clone(), file_name, stored_path, file_size);
let log_file = LogFile {
content_hash: content_hash.clone(),
mime_type: mime_type.to_string(),
@ -104,17 +274,36 @@ pub async fn upload_log_file_by_content(
content: String,
state: State<'_, AppState>,
) -> Result<LogFile, String> {
let fake_path = Path::new(&file_name);
if !is_safe_file(fake_path) {
let ext = fake_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)");
return Err(format!("File type '.{ext}' is not supported."));
}
let content_bytes = content.as_bytes();
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
let file_size = content_bytes.len() as i64;
// Determine mime type based on file extension
let mime_type = if file_name.ends_with(".json") {
"application/json"
} else if file_name.ends_with(".xml") {
"application/xml"
} else {
"text/plain"
let file_ext = fake_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
let mime_type = match file_ext.as_str() {
"json" => "application/json",
"xml" => "application/xml",
"yaml" | "yml" => "application/yaml",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"md" | "markdown" => "text/markdown",
"csv" | "tsv" => "text/csv",
"html" | "htm" => "text/html",
_ => "text/plain",
};
// Use the file_name as the file_path for DB storage
@ -328,4 +517,68 @@ mod tests {
assert!(result.is_ok());
let _ = std::fs::remove_file(file_path);
}
#[test]
fn test_is_safe_file_allows_txt() {
assert!(is_safe_file(Path::new("file.txt")));
}
#[test]
fn test_is_safe_file_allows_md() {
assert!(is_safe_file(Path::new("readme.md")));
}
#[test]
fn test_is_safe_file_allows_pdf() {
assert!(is_safe_file(Path::new("report.pdf")));
}
#[test]
fn test_is_safe_file_allows_docx() {
assert!(is_safe_file(Path::new("doc.docx")));
}
#[test]
fn test_is_safe_file_rejects_exe() {
assert!(!is_safe_file(Path::new("malware.exe")));
}
#[test]
fn test_is_safe_file_rejects_dll() {
assert!(!is_safe_file(Path::new("library.dll")));
}
#[test]
fn test_is_safe_file_rejects_zip_directly() {
assert!(!is_safe_file(Path::new("archive.zip")));
}
#[test]
fn test_is_safe_file_case_insensitive() {
assert!(is_safe_file(Path::new("file.TXT")));
assert!(is_safe_file(Path::new("file.Log")));
}
#[test]
fn test_is_safe_file_no_extension_rejected() {
assert!(!is_safe_file(Path::new("Makefile")));
}
#[test]
fn test_extract_text_plain_file() {
let dir = std::env::temp_dir();
let path = dir.join(format!("tftsr-test-extract-{}.txt", uuid::Uuid::now_v7()));
std::fs::write(&path, "hello world").unwrap();
let result = extract_text_content(&path);
assert!(result.is_ok());
assert_eq!(result.unwrap().trim(), "hello world");
let _ = std::fs::remove_file(path);
}
#[test]
fn test_extract_text_unsupported_binary_returns_error() {
let result = extract_text_content(Path::new("data.xlsx"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not yet supported"));
}
}

View File

@ -347,7 +347,7 @@ pub async fn list_issues(
let offset = filter.offset.unwrap_or(0);
let mut sql = String::from(
"SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1",
@ -384,9 +384,19 @@ pub async fn list_issues(
}
if let Some(ref search) = filter.search {
let pattern = format!("%{search}%");
let idx = params.len() + 1;
sql.push_str(&format!(
" AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})",
params.len() + 1
" AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))",
));
params.push(Box::new(pattern));
}
@ -635,3 +645,163 @@ pub async fn get_timeline_events(
.collect();
Ok(events)
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
fn setup_test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
conn
}
fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \
VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')",
rusqlite::params![id, title, description, &now, &now],
)
.unwrap();
}
fn run_search_query(conn: &Connection, search: &str) -> Vec<String> {
let pattern = format!("%{search}%");
let idx = 1;
let sql = format!(
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1 \
AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \
ORDER BY i.updated_at DESC"
);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map([&pattern], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
#[test]
fn test_search_finds_by_title() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details");
insert_issue(&conn, "issue-2", "Network timeout", "No details");
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_description() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Generic title",
"The pod was killed due to memory pressure",
);
insert_issue(&conn, "issue-2", "Other", "All fine");
let results = run_search_query(&conn, "memory pressure");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_ai_message_content() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now],
).unwrap();
let results = run_search_query(&conn, "deadlock in PostgreSQL");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_resolution_step_answer() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now],
).unwrap();
let results = run_search_query(&conn, "pool exhausted");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_log_file_name() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0],
).unwrap();
let results = run_search_query(&conn, "nginx-error");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_timeline_description() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
conn.execute(
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"],
).unwrap();
let results = run_search_query(&conn, "DNS resolution");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_no_duplicates_for_multiple_matches() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Kubernetes crash",
"Kubernetes pod killed",
);
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now],
).unwrap();
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results.len(), 1);
assert_eq!(results[0], "issue-1");
}
}

View File

@ -1,3 +1,4 @@
pub mod agentic;
pub mod ai;
pub mod analysis;
pub mod db;

View File

@ -284,3 +284,178 @@ pub async fn get_app_version() -> Result<String, String> {
.or_else(|_| env::var("CARGO_PKG_VERSION"))
.map_err(|e| format!("Failed to get version: {e}"))
}
// --- Sudo credential commands ---
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SudoConfigStatus {
pub configured: bool,
pub username: String,
pub updated_at: String,
}
/// Resolve the OS username to bind sudo credentials to.
fn resolve_sudo_username(provided: Option<String>) -> String {
provided
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "local".to_string())
})
}
#[tauri::command]
pub async fn set_sudo_password(
password: String,
username: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let encrypted = crate::integrations::auth::encrypt_token(&password)?;
let uname = resolve_sudo_username(username);
let db = state.db.lock().map_err(|e| e.to_string())?;
// DELETE then INSERT to guarantee exactly one row at all times.
// INSERT OR REPLACE with a freshly generated UUID never matches the
// existing primary key, so it inserts an additional row instead of
// replacing — this is the correct singleton pattern for SQLite.
db.execute("DELETE FROM sudo_config", [])
.map_err(|e| format!("Failed to clear sudo config: {e}"))?;
db.execute(
"INSERT INTO sudo_config (id, username, encrypted_password, created_at, updated_at) \
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
rusqlite::params![uuid::Uuid::now_v7().to_string(), uname, encrypted],
)
.map_err(|e| format!("Failed to store sudo config: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn get_sudo_config_status(
state: tauri::State<'_, AppState>,
) -> Result<SudoConfigStatus, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let result: Option<(String, String)> = db
.prepare("SELECT username, updated_at FROM sudo_config LIMIT 1")
.and_then(|mut stmt| {
stmt.query_row([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
})
.ok();
match result {
Some((username, updated_at)) => Ok(SudoConfigStatus {
configured: true,
username,
updated_at,
}),
None => Ok(SudoConfigStatus {
configured: false,
username: String::new(),
updated_at: String::new(),
}),
}
}
#[tauri::command]
pub async fn test_sudo_password(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let (encrypted, stored_username) = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.prepare("SELECT encrypted_password, username FROM sudo_config LIMIT 1")
.and_then(|mut stmt| {
stmt.query_row([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
})
.ok()
.ok_or("No sudo password configured".to_string())?
};
let password = crate::integrations::auth::decrypt_token(&encrypted)?;
// Scope the test to the stored username so credentials can only be
// verified for the user they were saved for.
let result = if stored_username.is_empty() {
crate::commands::agentic::run_sudo_command(&password, &["true"])
} else {
crate::commands::agentic::run_sudo_command(&password, &["-u", &stored_username, "true"])
}
.map_err(|e| format!("Sudo test failed: {e}"))?;
Ok(result.success)
}
#[tauri::command]
pub async fn clear_sudo_password(state: tauri::State<'_, AppState>) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM sudo_config", [])
.map_err(|e| format!("Failed to clear sudo config: {e}"))?;
Ok(())
}
#[cfg(test)]
mod sudo_tests {
use super::*;
fn setup_db() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
conn
}
#[test]
fn test_set_sudo_singleton_delete_then_insert() {
let conn = setup_db();
// Insert two stale rows directly to simulate the old broken behaviour
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id1', 'alice', 'enc1')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id2', 'bob', 'enc2')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2);
// Apply the correct singleton pattern
conn.execute("DELETE FROM sudo_config", []).unwrap();
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id3', 'charlie', 'enc3')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1, "exactly one row must remain after set");
let username: String = conn
.query_row("SELECT username FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(username, "charlie");
}
#[test]
fn test_resolve_sudo_username_uses_provided() {
let result = resolve_sudo_username(Some("alice".to_string()));
assert_eq!(result, "alice");
}
#[test]
fn test_resolve_sudo_username_rejects_blank() {
let result = resolve_sudo_username(Some(" ".to_string()));
// blank string should fall through to env-based default
assert!(!result.trim().is_empty(), "username must never be blank");
}
#[test]
fn test_resolve_sudo_username_defaults_to_env() {
let env_user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "local".to_string());
let result = resolve_sudo_username(None);
assert_eq!(result, env_user);
}
}

View File

@ -249,6 +249,16 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
);",
),
(
"019_create_sudo_config",
"CREATE TABLE IF NOT EXISTS sudo_config (
id TEXT PRIMARY KEY,
username TEXT NOT NULL DEFAULT '',
encrypted_password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
),
];
for (name, sql) in migrations {
@ -1034,4 +1044,61 @@ mod tests {
.unwrap();
assert_eq!(applied, 1, "018 should only be recorded once");
}
// ─── Migration 019: sudo_config ─────────────────────────────────────────────
#[test]
fn test_019_sudo_config_table_exists() {
let conn = setup_test_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_019_sudo_config_columns() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(sudo_config)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"username".to_string()));
assert!(columns.contains(&"encrypted_password".to_string()));
assert!(columns.contains(&"created_at".to_string()));
assert!(columns.contains(&"updated_at".to_string()));
}
#[test]
fn test_019_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "sudo_config table should exist after double-run");
let applied: i64 = conn
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = '019_create_sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(applied, 1, "019 should only be recorded once");
}
}

View File

@ -133,6 +133,10 @@ pub fn run() {
commands::system::update_settings,
commands::system::get_audit_log,
commands::system::get_app_version,
commands::system::set_sudo_password,
commands::system::get_sudo_config_status,
commands::system::test_sudo_password,
commands::system::clear_sudo_password,
// MCP Servers
mcp::commands::list_mcp_servers,
mcp::commands::create_mcp_server,

View File

@ -591,6 +591,26 @@ export function initiateMcpOauthCmd(id: string): Promise<void> {
return invoke<void>("initiate_mcp_oauth", { id });
}
// ─── Sudo credential commands ─────────────────────────────────────────────────
export interface SudoConfigStatus {
configured: boolean;
username: string;
updated_at: string;
}
export const setSudoPasswordCmd = (password: string, username?: string) =>
invoke<void>("set_sudo_password", { password, username: username ?? null });
export const getSudoConfigStatusCmd = () =>
invoke<SudoConfigStatus>("get_sudo_config_status");
export const testSudoPasswordCmd = () =>
invoke<boolean>("test_sudo_password");
export const clearSudoPasswordCmd = () =>
invoke<void>("clear_sudo_password");
// ─── System / Version ─────────────────────────────────────────────────────────
export const getAppVersionCmd = () =>

View File

@ -252,8 +252,21 @@ export default function LogUpload() {
multiple
className="hidden"
onChange={handleFileSelect}
accept=".log,.txt,.json,.csv,.xml,.yaml,.yml"
accept=".log,.txt,.out,.err,.syslog,.journal,.yaml,.yml,.json,.toml,.xml,.ini,.cfg,.conf,.config,.env,.properties,.md,.markdown,.rst,.csv,.tsv,.ndjson,.jsonl,.sql,.sh,.bash,.zsh,.py,.js,.ts,.rb,.go,.rs,.java,.html,.htm,.css,.diff,.patch,.pdf,.docx,.doc,.rtf,.xlsx,.xls"
/>
<details className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<summary className="cursor-pointer hover:text-gray-700 dark:hover:text-gray-200">
Supported formats
</summary>
<div className="mt-1 pl-3 space-y-1">
<div><span className="font-medium">Logs &amp; text:</span> .log, .txt, .out, .err, .syslog, .journal</div>
<div><span className="font-medium">Config &amp; markup:</span> .yaml, .yml, .json, .toml, .xml, .ini, .cfg, .conf, .env, .properties</div>
<div><span className="font-medium">Documents:</span> .pdf, .docx, .doc, .md, .rst, .rtf</div>
<div><span className="font-medium">Data:</span> .csv, .tsv, .xlsx, .xls, .ndjson, .jsonl, .sql</div>
<div><span className="font-medium">Code &amp; scripts:</span> .sh, .bash, .zsh, .py, .js, .ts, .rb, .go, .rs, .java, .html, .css, .diff, .patch</div>
<p className="mt-1 italic">Binary formats (PDF, DOCX, XLSX) will have their text extracted automatically.</p>
</div>
</details>
</div>
{/* File list */}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Shield, RefreshCw } from "lucide-react";
import { Shield, RefreshCw, Lock } from "lucide-react";
import {
Card,
CardHeader,
@ -7,7 +7,15 @@ import {
CardContent,
Badge,
} from "@/components/ui";
import { getAuditLogCmd, type AuditEntry } from "@/lib/tauriCommands";
import {
getAuditLogCmd,
getSudoConfigStatusCmd,
setSudoPasswordCmd,
testSudoPasswordCmd,
clearSudoPasswordCmd,
type AuditEntry,
type SudoConfigStatus,
} from "@/lib/tauriCommands";
import { useSettingsStore } from "@/stores/settingsStore";
const piiPatterns = [
@ -28,8 +36,15 @@ export default function Security() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sudoPassword, setSudoPassword] = useState("");
const [sudoUsername, setSudoUsername] = useState("");
const [sudoStatus, setSudoStatus] = useState<SudoConfigStatus | null>(null);
const [sudoMessage, setSudoMessage] = useState("");
const [sudoTesting, setSudoTesting] = useState(false);
useEffect(() => {
loadAuditLog();
loadSudoStatus();
}, []);
const loadAuditLog = async () => {
@ -44,6 +59,51 @@ export default function Security() {
}
};
const loadSudoStatus = async () => {
try {
const status = await getSudoConfigStatusCmd();
setSudoStatus(status);
} catch {
// ignore — table may not exist yet
}
};
const handleSaveSudo = async () => {
setSudoMessage("");
try {
await setSudoPasswordCmd(sudoPassword, sudoUsername || undefined);
setSudoPassword("");
setSudoMessage("Saved successfully");
await loadSudoStatus();
} catch (err) {
setSudoMessage(`Error: ${String(err)}`);
}
};
const handleTestSudo = async () => {
setSudoTesting(true);
setSudoMessage("");
try {
const ok = await testSudoPasswordCmd();
setSudoMessage(ok ? "Password verified" : "Authentication failed");
} catch (err) {
setSudoMessage(`Authentication failed: ${String(err)}`);
} finally {
setSudoTesting(false);
}
};
const handleClearSudo = async () => {
setSudoMessage("");
try {
await clearSudoPasswordCmd();
setSudoMessage("Credentials cleared");
await loadSudoStatus();
} catch (err) {
setSudoMessage(`Error: ${String(err)}`);
}
};
const toggleRow = (entryId: string) => {
setExpandedRows((prev) => {
const newSet = new Set(prev);
@ -103,6 +163,86 @@ export default function Security() {
</CardContent>
</Card>
{/* Sudo Credentials */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Lock className="w-5 h-5" />
Sudo Credentials
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{sudoStatus?.configured && (
<div className="text-sm text-green-600 space-y-0.5">
<p>Configured for <strong>{sudoStatus.username}</strong></p>
<p className="text-xs text-muted-foreground">Last updated: {sudoStatus.updated_at}</p>
</div>
)}
{sudoStatus && !sudoStatus.configured && (
<p className="text-sm text-muted-foreground">Not configured</p>
)}
<div className="space-y-3">
<div>
<label className="text-sm font-medium" htmlFor="sudo-username">
Username
</label>
<input
id="sudo-username"
type="text"
value={sudoUsername}
onChange={(e) => setSudoUsername(e.target.value)}
placeholder="Defaults to current OS user"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Credentials are scoped to this user. Leave blank to use the current OS user.
</p>
</div>
<div>
<label className="text-sm font-medium" htmlFor="sudo-password">
Password
</label>
<input
id="sudo-password"
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Enter sudo password"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveSudo}
disabled={!sudoPassword}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
<button
onClick={handleTestSudo}
disabled={sudoTesting || !sudoStatus?.configured}
className="px-3 py-1.5 text-sm rounded-md border border-input hover:bg-accent disabled:opacity-50"
>
{sudoTesting ? "Testing..." : "Test"}
</button>
<button
onClick={handleClearSudo}
disabled={!sudoStatus?.configured}
className="px-3 py-1.5 text-sm rounded-md border border-destructive text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
Clear
</button>
</div>
{sudoMessage && (
<p className={`text-sm ${sudoMessage.startsWith("Error") || sudoMessage.includes("failed") ? "text-destructive" : "text-green-600"}`}>
{sudoMessage}
</p>
)}
</CardContent>
</Card>
{/* Audit Log */}
<Card>
<CardHeader>