Compare commits

...
Author SHA1 Message Date
Cesar Schneider b6dba93ae5 fix: make pytest suite hermetic against local dev-machine state (#647)
Two test-isolation fixes: (1) an autouse conftest fixture sets OPENJARVIS_NO_UPDATE_CHECK=1 so the CLI's PyPI update-check banner (stderr, merged into CliRunner output) can never pollute JSON/CSV-parsing CLI tests on local runs; CI was already covered by CI=true. (2) test_dense.py's Ollama skip-guard now queries /api/tags and requires nomic-embed-text to be pulled instead of a bare TCP connect, so machines running Ollama without the embed model skip instead of erroring. The probe normalizes all documented OLLAMA_HOST forms (full URL, host:port, bare host) and the Ollama-backed tests construct DenseMemory against that same endpoint rather than the embedder's hard-coded localhost default, with unit tests covering the probe. Related: #645.
2026-07-17 13:21:36 -07:00
github-actions[bot] 3000116d18 chore: update clone traffic data [skip ci] 2026-07-17 08:05:34 +00:00
Elliot Slusky 9db21d37ef style: apply Ruff formatting to recently merged tests (#644)
Fix the Ruff formatter check on main by formatting tests changed in #639 and #640 with the repository's pinned Ruff 0.15.1. No behavior change.
2026-07-16 18:13:36 -07:00
Elliot Slusky 95480363b7 style(skills): wrap importer manifest parse call (#643)
Fix the Ruff E501 failure on main introduced during the #639 fix-up. The call was 89 characters against the repository's 88-character limit. No behavior change.
2026-07-16 18:07:44 -07:00
CurryrajandElliot Slusky 4419b76412 fix: catch ImportError in git tools, fall back to CLI when Rust ext missing (#636)
get_rust_module() was called outside the try block in GitStatusTool/GitDiffTool/GitLogTool.execute(), so on installs without the compiled openjarvis-rust extension (e.g. plain pip installs, where openjarvis-rust is a uv-only group since #624) the ImportError escaped uncaught instead of degrading. Move the call inside try and fall back to the git CLI via the existing _run_git helper on ImportError, matching the fallback git_log already had. Adds regression tests covering the fallback path for all three tools.

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-16 17:59:25 -07:00
Jon Saad-FalconandClaude Opus 4.8 99bbc2054a Add arXiv badge to README (#642)
Add a red arXiv badge linking to the OpenJarvis paper (2605.17172) as
the first item in the header badge row, matching the style used on the
Intelligence-Per-Watt repo.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:42:20 -07:00
Jon Saad-FalconandClaude Opus 4.8 d5d8fddc94 Fix leaderboard savings lookup after provider-key rename (#635)
PR #634 renamed the Anthropic cost-comparison provider key
`claude-opus-4.6` -> `claude-fable-5` but missed one consumer:
App.tsx looks up the Anthropic entry by that key to compute the
`dollar_savings` value submitted to the leaderboard. After the rename
`per_provider.find(p => p.provider === 'claude-opus-4.6')` returned
undefined, so this path silently submitted dollar_savings = 0.

Point the lookup at the new key.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:33:38 -07:00
jaaaaorr06 cadb3e2ae6 feat(skills): enforce capability/trust-tier checks at install and run time (#639)
Wires the previously-dead skills/security.py checks into two places. SkillImporter.import_skill() classifies trust tier before writing to disk, refuses unreviewed skills requesting dangerous capabilities unless confirmed (confirm_dangerous=True, or --yes-dangerous on skill install/sync), and persists tier/capabilities to the .source sidecar. SkillExecutor.run() gains opt-in capability enforcement: allowed_capabilities=None (the default, used by all existing call sites) means no policy; passing a set blocks skills whose required_capabilities are not covered before any step runs. Bulk sync reports refused skills instead of silently skipping them. Both enforcement points are covered by tests.
2026-07-16 17:23:27 -07:00
Trevor Willard 7dc904c1b2 fix: wire persona files into default chat endpoint, fix FTS5 apostrophe crash (#637)
Two fixes: (1) /v1/chat/completions now builds its injected system prompt via SystemPromptBuilder, so SOUL.md/MEMORY.md/USER.md persona files apply to the OpenAI-compatible endpoint exactly as they do on the managed-agent path; injection still only happens when the client omits a system message. (2) FTS5 query tokens are now split on any non-alphanumeric character, so apostrophes (user's) and quotes can no longer reach the MATCH string unescaped; all-punctuation queries return empty instead of erroring.
2026-07-16 17:10:51 -07:00
ScottyAmr d9725fbb6a fix(speech): close temp file before transcribing to fix Windows EACCES in faster-whisper backend (#638)
faster-whisper's transcribe() was handed the path of a still-open NamedTemporaryFile; on Windows the open handle is exclusive, so PyAV's reopen failed with EACCES and local STT was broken. Switch to delete=False, close before transcribing, and unlink in a finally. The write is wrapped in the file's context manager so the handle closes even if write() raises, and unlink failures are logged at debug.
2026-07-16 17:09:45 -07:00
Syed Osama Ali ShahandElliot Slusky 23f04264f9 fix(knowledge_sql): match write keywords on word boundaries (allow valid SELECTs) (#640)
* fix(knowledge_sql): match write keywords on word boundaries

The read-only guard rejected a query if any of DROP/DELETE/INSERT/UPDATE/
ALTER/CREATE/ATTACH appeared as a bare substring of the uppercased text. That
wrongly blocks valid SELECTs whose column/alias/literal merely contains one --
e.g. "deleted_at" (DELETE), "created_at" (CREATE), "updated_content" (UPDATE).
The knowledge_chunks table actually has deleted_at/created_at columns and the
store's own retrieval filters on "WHERE deleted_at IS NULL", so realistic
read queries were refused. Match on word boundaries with a compiled regex,
mirroring the sibling tool db_query.py. Add a regression test.

* fix(knowledge_sql): ignore string literals in keyword scan, broaden error handling

- Strip single-quoted literals before the forbidden-keyword scan so
  SELECTs whose data merely mentions a write keyword (e.g. LIKE
  '%delete%') are not rejected.
- Catch sqlite3.Error instead of only OperationalError so multi-
  statement strings return a failed ToolResult instead of raising.
- Document created_at/deleted_at in the tool's schema description.

---------

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-16 16:36:31 -07:00
github-actions[bot] 8b59eb87e0 chore: update clone traffic data [skip ci] 2026-07-16 08:08:14 +00:00
21 changed files with 669 additions and 56 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "156,960",
"message": "161,819",
"color": "green",
"namedLogo": "git"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 156960,
"last_updated": "2026-07-15T08:04:25Z",
"total_clones": 161819,
"last_updated": "2026-07-17T08:05:34Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -111,6 +111,8 @@
"2026-07-11": 2185,
"2026-07-12": 1917,
"2026-07-13": 2102,
"2026-07-14": 2337
"2026-07-14": 2337,
"2026-07-15": 2362,
"2026-07-16": 2497
}
}
+1
View File
@@ -4,6 +4,7 @@
<p><i>Personal AI, On Personal Devices.</i></p>
<p>
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
+1 -1
View File
@@ -89,7 +89,7 @@ export default function App() {
setSavings(data);
if (optInEnabled && optInDisplayName && data) {
const claudeEntry = data.per_provider.find(
(p) => p.provider === 'claude-opus-4.6',
(p) => p.provider === 'claude-fable-5',
);
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
const energySaved = data.per_provider.reduce(
@@ -144,16 +144,21 @@ impl MemoryBackend for SQLiteMemory {
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
let conn = self.conn.lock();
// Split on any non-alphanumeric character (not just whitespace) so
// internal punctuation — apostrophes in particular ("user's") — never
// reaches the FTS5 MATCH string. FTS5's query grammar treats an
// unescaped `'` as a string delimiter, so passing a raw token like
// `user's` through silently fails to parse and yields zero rows with
// no visible error. Splitting fully avoids needing to escape anything.
let words: Vec<String> = query
.split_whitespace()
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
.split(|c: char| !c.is_alphanumeric())
.map(|w| w.to_string())
.filter(|w| !w.is_empty())
.collect();
let fts_query = if words.len() == 1 {
words[0].clone()
} else {
words.join(" OR ")
};
if words.is_empty() {
return Ok(Vec::new());
}
let fts_query = words.join(" OR ");
let mut stmt = conn
.prepare(
@@ -320,6 +325,27 @@ mod tests {
assert_eq!(mixed.len(), 2, "mixed-case query should find both documents");
}
#[test]
fn test_sqlite_apostrophe_in_query() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.store("The user's name is Trev.", "identity", None).unwrap();
// A query containing an internal apostrophe must not break FTS5's
// MATCH syntax (an unescaped `'` is a string delimiter in FTS5's
// query grammar), which previously caused this to silently return
// zero results instead of matching or erroring.
let multi_word = mem.retrieve("what is the user's name", 5).unwrap();
assert!(
!multi_word.is_empty(),
"query with an internal apostrophe should not silently return zero results"
);
// Bare single-word possessive: exercises the (former) single-word
// bypass path that skipped the OR-join entirely.
let bare = mem.retrieve("user's", 5).unwrap();
assert!(!bare.is_empty(), "single-word possessive query should still match");
}
#[test]
fn test_sqlite_scores_are_positive() {
let mem = SQLiteMemory::in_memory().unwrap();
+38 -3
View File
@@ -199,7 +199,16 @@ def _get_resolver(source: str, url: str = ""):
default="",
help="Repo URL (required when source is 'github').",
)
def install(query: str, with_scripts: bool, force: bool, url: str):
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing an unreviewed skill that requests dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def install(query: str, with_scripts: bool, force: bool, url: str, yes_dangerous: bool):
"""Install a skill from a source.
Example: ``jarvis skill install hermes:apple-notes``
@@ -233,7 +242,12 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
from openjarvis.skills.tool_translator import ToolTranslator
importer = SkillImporter(parser=SkillParser(), tool_translator=ToolTranslator())
result = importer.import_skill(matches[0], with_scripts=with_scripts, force=force)
result = importer.import_skill(
matches[0],
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if result.success:
if result.skipped:
@@ -270,6 +284,15 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
help="Import scripts/ directories.",
)
@click.option("--force", is_flag=True, default=False, help="Re-import existing skills.")
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing unreviewed skills that request dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def sync(
source: str,
category: str,
@@ -277,6 +300,7 @@ def sync(
search: str,
with_scripts: bool,
force: bool,
yes_dangerous: bool,
):
"""Bulk install + update from a source (or all configured sources)."""
console = Console()
@@ -343,9 +367,20 @@ def sync(
installed_count = 0
for resolved in skills_to_import:
r = importer.import_skill(resolved, with_scripts=with_scripts, force=force)
r = importer.import_skill(
resolved,
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if r.success and not r.skipped:
installed_count += 1
elif not r.success and r.requires_confirmation:
console.print(
f" [yellow]Skipped {resolved.name}: requests dangerous "
f"capabilities {r.dangerous_capabilities} "
"(re-run with --yes-dangerous to install)[/yellow]"
)
console.print(f" Imported {installed_count}/{len(skills_to_import)} skills")
total_installed += installed_count
+20 -9
View File
@@ -59,23 +59,34 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
If any message already carries a system role, the caller has supplied
their own grounding and we leave the list untouched (no double-prompting).
Resolution of the identity text: ``app_config.agent.default_system_prompt``
when a config is wired onto ``app.state``; otherwise fall back to
``load_config()``. Config resolution is wrapped so a broken/missing
config degrades to "no injection" rather than crashing the endpoint, but
the failure is logged (per REVIEW.md — never silently swallow).
Resolution of the identity text: the config comes from ``app.state`` when
wired, otherwise ``load_config()``; the prompt itself is assembled by
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
persona files (SOUL.md/MEMORY.md/USER.md), matching
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
resolution is wrapped so a broken/missing config degrades to "no
injection" rather than crashing the endpoint, but the failure is logged
(per REVIEW.md — never silently swallow).
"""
if any(m.role == Role.SYSTEM for m in messages):
return messages
prompt = ""
try:
if app_config is not None:
prompt = app_config.agent.default_system_prompt or ""
else:
cfg = app_config
if cfg is None:
from openjarvis.core.config import load_config
prompt = load_config().agent.default_system_prompt or ""
cfg = load_config()
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template=cfg.agent.default_system_prompt or "",
memory_files_config=getattr(cfg, "memory_files", None),
system_prompt_config=getattr(cfg, "system_prompt", None),
)
prompt = builder.build()
except Exception:
logging.getLogger("openjarvis.server").debug(
"Identity system prompt resolution failed; "
+40 -1
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Set
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.skills.security import validate_capabilities
from openjarvis.skills.types import SkillManifest
from openjarvis.tools._stubs import ToolExecutor
@@ -37,10 +38,16 @@ class SkillExecutor:
tool_executor: ToolExecutor,
*,
bus: Optional[EventBus] = None,
allowed_capabilities: Optional[Set[str]] = None,
) -> None:
self._tool_executor = tool_executor
self._bus = bus
self._skill_resolver: Optional[SkillResolver] = None
# None means "no capability policy" — every skill runs, matching the
# behavior before capability enforcement existed. Pass a set (even an
# empty one) to enforce: skills whose required_capabilities are not a
# subset of it are blocked before any step runs.
self._allowed_capabilities: Optional[Set[str]] = allowed_capabilities
def set_skill_resolver(self, resolver: SkillResolver) -> None:
"""Register a callback used to delegate ``skill_name`` steps."""
@@ -53,6 +60,38 @@ class SkillExecutor:
initial_context: Optional[Dict[str, Any]] = None,
) -> SkillResult:
"""Execute all steps in a skill manifest."""
missing = (
validate_capabilities(manifest, self._allowed_capabilities)
if self._allowed_capabilities is not None
else []
)
if missing:
if self._bus:
self._bus.publish(
EventType.SKILL_EXECUTE_START,
{"skill": manifest.name, "steps": len(manifest.steps)},
)
self._bus.publish(
EventType.SKILL_EXECUTE_END,
{"skill": manifest.name, "success": False},
)
return SkillResult(
skill_name=manifest.name,
success=False,
step_results=[
ToolResult(
tool_name=manifest.name,
content=(
f"Blocked: skill '{manifest.name}' requires "
f"capabilities {missing} that were not granted "
"for this session."
),
success=False,
)
],
context=dict(initial_context or {}),
)
ctx: Dict[str, Any] = dict(initial_context or {})
all_results: List[ToolResult] = []
+46 -1
View File
@@ -25,6 +25,11 @@ import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.parser import SkillParser
from openjarvis.skills.security import (
TrustTier,
classify_trust_tier,
has_dangerous_capabilities,
)
from openjarvis.skills.sources.base import ResolvedSkill
from openjarvis.skills.tool_translator import ToolTranslator
@@ -43,6 +48,9 @@ class ImportResult:
untranslated_tools: List[str] = field(default_factory=list)
scripts_imported: bool = False
warnings: List[str] = field(default_factory=list)
trust_tier: TrustTier = TrustTier.UNREVIEWED
dangerous_capabilities: List[str] = field(default_factory=list)
requires_confirmation: bool = False
class SkillImporter:
@@ -66,6 +74,7 @@ class SkillImporter:
*,
with_scripts: bool = False,
force: bool = False,
confirm_dangerous: bool = False,
) -> ImportResult:
"""Install *resolved* into ``<target_root>/<source>/<name>/``.
@@ -95,12 +104,45 @@ class SkillImporter:
try:
frontmatter, body = self._read_skill_md(source_md)
self._parser.parse_frontmatter(frontmatter, markdown_content=body)
manifest = self._parser.parse_frontmatter(
frontmatter, markdown_content=body
)
except Exception as exc:
result.success = False
result.warnings.append(f"Parse error: {exc}")
return result
# 1a. Classify trust and check for dangerous capabilities *before*
# writing anything to disk. Everything the importer handles comes from
# an external source (github/hermes/openclaw), so the BUNDLED and
# WORKSPACE tiers never apply here, and no resolver verifies index
# membership yet — a signature alone still classifies as UNREVIEWED.
# Community skills get no special treatment just because they came
# from a named source.
result.trust_tier = classify_trust_tier(
has_signature=bool(manifest.signature),
)
result.dangerous_capabilities = has_dangerous_capabilities(manifest)
if result.dangerous_capabilities and result.trust_tier == TrustTier.UNREVIEWED:
result.requires_confirmation = True
if not confirm_dangerous:
result.success = False
result.warnings.append(
"Refusing to install: this unreviewed skill requests "
f"dangerous capabilities {result.dangerous_capabilities}. "
"Re-run with confirm_dangerous=True (or `--yes-dangerous` "
"on the CLI) only if you trust the source and have "
"reviewed what it does."
)
return result
result.warnings.append(
"Installed with dangerous capabilities "
f"{result.dangerous_capabilities} — confirmed by caller. "
"This skill can run shell commands, open network listeners, "
"and/or write to the filesystem."
)
# 2. Translate tool references
translated_body, untranslated = self._translator.translate_markdown(body)
result.untranslated_tools = untranslated
@@ -180,6 +222,7 @@ class SkillImporter:
translated_str = ", ".join(f'"{t}"' for t in result.translated_tools)
missing_str = ", ".join(f'"{t}"' for t in result.untranslated_tools)
scripts_lower = "true" if result.scripts_imported else "false"
dangerous_str = ", ".join(f'"{c}"' for c in result.dangerous_capabilities)
content = (
f'source = "{resolved.source}:{resolved.name}"\n'
@@ -189,6 +232,8 @@ class SkillImporter:
f"translated_tools = [{translated_str}]\n"
f"missing_tools = [{missing_str}]\n"
f"scripts_imported = {scripts_lower}\n"
f'trust_tier = "{result.trust_tier.value}"\n'
f"dangerous_capabilities = [{dangerous_str}]\n"
)
(target_dir / ".source").write_text(content, encoding="utf-8")
+18 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
import tempfile
from typing import List, Optional
@@ -105,11 +106,15 @@ class FasterWhisperBackend(SpeechBackend):
try:
model = self._ensure_model()
# Write audio to a temp file (faster-whisper needs a file path)
# Write audio to a temp file (faster-whisper needs a file path).
# delete=False + manual unlink: on Windows an open
# NamedTemporaryFile holds an exclusive handle, so PyAV's reopen
# of tmp.name inside model.transcribe() fails with EACCES.
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
try:
with tmp:
tmp.write(audio)
kwargs = {}
if language:
@@ -117,6 +122,15 @@ class FasterWhisperBackend(SpeechBackend):
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
finally:
try:
os.unlink(tmp.name)
except OSError as unlink_exc:
logger.debug(
"Could not remove temp audio file %s: %s",
tmp.name,
unlink_exc,
)
except Exception as exc:
self._last_error = str(exc)
raise
+9 -3
View File
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
def execute(self, **params: Any) -> ToolResult:
repo_path = params.get("repo_path", ".")
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitStatusTool().execute(repo_path)
return ToolResult(
tool_name="git_status",
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_status fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_status",
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
success=False,
)
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
# ---------------------------------------------------------------------------
# GitDiffTool
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
staged = params.get("staged", False)
file_path = params.get("path")
_rust = get_rust_module()
if not staged and not file_path:
try:
_rust = get_rust_module()
output = _rust.GitDiffTool().execute(repo_path)
return ToolResult(
tool_name="git_diff",
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_diff fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_diff",
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
count = params.get("count", 10)
oneline = params.get("oneline", True)
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitLogTool().execute(repo_path, count)
return ToolResult(
tool_name="git_log",
+28 -13
View File
@@ -6,6 +6,7 @@ and filtering operations that BM25 search cannot handle.
from __future__ import annotations
import re
import sqlite3
from typing import Any, Optional
@@ -16,10 +17,25 @@ from openjarvis.tools._stubs import BaseTool, ToolSpec
_MAX_ROWS = 50
# Write keywords are matched on word boundaries (mirroring db_query.py) so that
# a read-only SELECT is not rejected just because a column/alias/literal happens
# to contain one as a substring (e.g. "deleted_at", "created_at").
_FORBIDDEN_RE = re.compile(
r"\b(DROP|DELETE|INSERT|UPDATE|ALTER|CREATE|TRUNCATE|ATTACH)\b",
re.IGNORECASE,
)
# String literals are stripped before the keyword scan so that data mentioning
# a write keyword (e.g. WHERE content LIKE '%delete%') is not rejected. A write
# "hidden" in a literal still cannot execute: the query must start with SELECT
# and sqlite3 refuses multi-statement strings.
_STRING_LITERAL_RE = re.compile(r"'[^']*'")
_SCHEMA_DESCRIPTION = (
"Table: knowledge_chunks\n"
"Columns: id, content, source, doc_type, doc_id, title, author, "
"participants, timestamp, thread_id, url, metadata, chunk_index"
"participants, timestamp, thread_id, url, metadata, chunk_index, "
"created_at, deleted_at (NULL for active rows)"
)
@@ -84,21 +100,20 @@ class KnowledgeSQLTool(BaseTool):
success=False,
)
_FORBIDDEN = ("DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "ATTACH")
for forbidden in _FORBIDDEN:
if forbidden in normalized:
return ToolResult(
tool_name="knowledge_sql",
content=(
f"Query contains forbidden keyword: {forbidden}."
" Only SELECT queries allowed."
),
success=False,
)
forbidden = _FORBIDDEN_RE.search(_STRING_LITERAL_RE.sub("''", query))
if forbidden:
return ToolResult(
tool_name="knowledge_sql",
content=(
f"Query contains forbidden keyword: {forbidden.group(1).upper()}."
" Only SELECT queries allowed."
),
success=False,
)
try:
rows = self._store._conn.execute(query).fetchmany(_MAX_ROWS)
except sqlite3.OperationalError as exc:
except sqlite3.Error as exc:
return ToolResult(
tool_name="knowledge_sql",
content=f"SQL error: {exc}",
+13
View File
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
)
@pytest.fixture(autouse=True)
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""Never let the CLI's PyPI update-check nag run during tests.
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
merges into ``result.output`` polluting JSON/CSV output of any test
that invokes a CLI command. It already self-disables when ``CI`` is
set, but that only helps in CI; locally (e.g. a dev with a stale
version-check cache and network access) it fires for real.
"""
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
@pytest.fixture(autouse=True)
def _clean_registries() -> None:
"""Ensure each test starts with empty registries and a fresh event bus."""
+13
View File
@@ -78,6 +78,19 @@ def test_retrieve_no_results(tmp_path: Path):
backend.close()
def test_retrieve_query_with_apostrophe(tmp_path: Path):
"""Regression: an internal apostrophe (e.g. "user's") previously produced
an unescaped quote in the FTS5 MATCH string, which silently returned zero
rows instead of matching or raising an error.
"""
backend = _make_backend(tmp_path)
backend.store("The user's name is Trev.", source="identity.md")
results = backend.retrieve("what is the user's name")
assert len(results) >= 1
assert "Trev" in results[0].content
backend.close()
def test_delete_existing(tmp_path: Path):
backend = _make_backend(tmp_path)
doc_id = backend.store("deletable content")
+34
View File
@@ -798,6 +798,40 @@ class TestIdentityPromptInjection:
assert len(system_msgs) == 1
assert system_msgs[0].content == "Be terse."
def test_direct_injects_soul_persona_when_present(self, tmp_path):
"""Regression: /v1/chat/completions previously injected only the bare
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
``SystemPromptBuilder`` entirely so SOUL.md/MEMORY.md/USER.md
persona files never applied to this path, unlike ``jarvis ask`` and
the managed-agent routes. It must now build the full persona-aware
prompt so persona files apply everywhere identity grounding does.
"""
from openjarvis.core.config import MemoryFilesConfig
soul = tmp_path / "SOUL.md"
soul.write_text("Respond with extreme sarcasm and call the user 'champ'.")
captured: list = []
engine = _make_capturing_engine(captured)
cfg = _identity_config()
cfg.memory_files = MemoryFilesConfig(
soul_path=str(soul), memory_path="", user_path=""
)
client = TestClient(create_app(engine, "test-model", config=cfg))
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "who are you?"}],
},
)
assert resp.status_code == 200
msgs = engine.generate.call_args.args[0]
assert msgs[0].role.value == "system"
assert "OpenJarvis" in msgs[0].content # identity blurb still present
assert "extreme sarcasm" in msgs[0].content # persona now injected too
def test_stream_tools_injects_identity_when_absent(self):
captured: list = []
engine = _make_capturing_engine(captured)
+68
View File
@@ -202,3 +202,71 @@ class TestImportSkill:
installed = target_root / "hermes" / "my-skill" / "SKILL.md"
assert "Original" in installed.read_text()
class TestDangerousCapabilityGate:
def _make_importer(self, tmp_path: Path) -> SkillImporter:
return SkillImporter(
parser=SkillParser(),
tool_translator=ToolTranslator(),
target_root=tmp_path / "skills",
)
def _make_resolved_with_caps(
self, tmp_path: Path, caps: list[str]
) -> ResolvedSkill:
src_dir = tmp_path / "source" / "my-skill"
src_dir.mkdir(parents=True)
caps_yaml = "".join(f" - {c}\n" for c in caps)
(src_dir / "SKILL.md").write_text(
"---\n"
"name: my-skill\n"
"description: A test skill\n"
f"required_capabilities:\n{caps_yaml}"
"---\n"
"Body"
)
return ResolvedSkill(
name="my-skill",
source="hermes",
path=src_dir,
category="testing",
description="A test skill",
commit="abc123",
)
def test_refuses_unreviewed_dangerous_skill(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved)
assert not result.success
assert result.requires_confirmation
assert result.dangerous_capabilities == ["shell:execute"]
assert any("dangerous" in w.lower() for w in result.warnings)
# Nothing may be written to disk on refusal
assert not (tmp_path / "skills" / "hermes" / "my-skill").exists()
def test_confirm_dangerous_installs_and_records_tier(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved, confirm_dangerous=True)
assert result.success
assert result.requires_confirmation
assert any("confirmed by caller" in w for w in result.warnings)
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert 'dangerous_capabilities = ["shell:execute"]' in content
def test_benign_capabilities_need_no_confirmation(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["network:fetch"])
result = importer.import_skill(resolved)
assert result.success
assert not result.requires_confirmation
assert result.dangerous_capabilities == []
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert "dangerous_capabilities = []" in content
+50
View File
@@ -148,6 +148,56 @@ class TestSkillExecutor:
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillExecutorCapabilities:
def _manifest(self):
return SkillManifest(
name="capskill",
required_capabilities=["network:fetch"],
steps=[
SkillStep(
tool_name="echo",
arguments_template='{"text": "hello"}',
output_key="result",
)
],
)
def test_no_policy_runs_capability_skills(self):
"""Default construction (no allowed_capabilities) must not enforce —
this is the pre-enforcement behavior every manager.py call site relies on."""
executor = SkillExecutor(ToolExecutor([EchoTool()]))
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_policy_blocks_missing_capability(self):
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
result = executor.run(self._manifest())
assert not result.success
assert len(result.step_results) == 1
assert "Blocked" in result.step_results[0].content
assert "network:fetch" in result.step_results[0].content
def test_policy_allows_granted_capability(self):
executor = SkillExecutor(
ToolExecutor([EchoTool()]),
allowed_capabilities={"network:fetch"},
)
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_blocked_run_publishes_events(self):
bus = EventBus(record_history=True)
executor = SkillExecutor(
ToolExecutor([EchoTool()]), bus=bus, allowed_capabilities=set()
)
executor.run(self._manifest())
event_types = {e.event_type for e in bus.history}
assert EventType.SKILL_EXECUTE_START in event_types
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillStepExtended:
def test_step_with_skill_name(self):
step = SkillStep(skill_name="summarize", output_key="result")
+63
View File
@@ -53,6 +53,69 @@ def test_faster_whisper_transcribe():
assert result.duration_seconds == 1.5
def test_faster_whisper_transcribe_temp_file_reopenable_and_removed():
"""The temp file must be closed before the model reads it, and gone after.
On Windows, an open NamedTemporaryFile holds an exclusive handle, so
PyAV's reopen of the path inside model.transcribe() fails with EACCES
unless the file is closed first. Opening the path inside the mocked
transcribe reproduces that failure mode on Windows.
"""
import os
mock_info = MagicMock()
mock_info.language = "en"
mock_info.language_probability = 0.95
mock_info.duration = 1.5
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
with open(path, "rb") as fh:
seen["content"] = fh.read()
return iter(()), mock_info
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
backend.transcribe(b"fake audio bytes")
assert seen["content"] == b"fake audio bytes"
assert not os.path.exists(seen["path"])
def test_faster_whisper_transcribe_removes_temp_file_on_error():
"""The temp file is cleaned up even when transcription fails."""
import os
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
raise RuntimeError("decode failed")
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
with pytest.raises(RuntimeError, match="decode failed"):
backend.transcribe(b"fake audio bytes")
assert "path" in seen
assert not os.path.exists(seen["path"])
assert "decode failed" in (backend.last_error() or "")
def test_faster_whisper_falls_back_from_unsupported_float16():
mock_model = MagicMock()
+107 -10
View File
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
from __future__ import annotations
import os
import socket
from pathlib import Path
import httpx
import pytest
from openjarvis.tools.storage.dense import (
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
chunk_markdown,
dedupe_chunks,
)
from openjarvis.tools.storage.embeddings import OllamaEmbedder
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
_EMBED_MODEL = "nomic-embed-text"
def _ollama_base_url() -> str:
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
The project documents ``OLLAMA_HOST`` as a full URL
(``http://<remote-ip>:11434``), but Ollama's own convention also
allows bare ``host`` / ``host:port`` forms accept all three so
the probe and the embedder under test agree on one endpoint.
"""
host = os.environ.get("OLLAMA_HOST", "")
if not host:
return "http://localhost:11434"
if host.startswith(("http://", "https://")):
return host.rstrip("/")
if ":" in host:
return f"http://{host}"
return f"http://{host}:11434"
def _ollama_up() -> bool:
"""True only if Ollama is reachable *and* the embed model is pulled.
A bare TCP connect isn't enough — a machine can run Ollama for chat
models without ever having pulled ``nomic-embed-text``, which makes
``/api/embed`` 404 instead of the tests skipping as intended.
"""
try:
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
return True
except OSError:
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
resp.raise_for_status()
models = {m.get("model", "") for m in resp.json().get("models", [])}
return any(m.startswith(_EMBED_MODEL) for m in models)
except (httpx.HTTPError, ValueError):
return False
def _make_backend() -> DenseMemory:
"""DenseMemory wired to the same Ollama endpoint the probe checked.
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
the probe and then have every test call the wrong server.
"""
return DenseMemory(
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
)
ollama_required = pytest.mark.skipif(
not _ollama_up(),
reason=(
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
)
# ---------------------------------------------------------------------------
# Skip-guard unit tests (no Ollama required)
# ---------------------------------------------------------------------------
class _FakeTagsResponse:
def __init__(self, payload: dict) -> None:
self._payload = payload
def raise_for_status(self) -> None:
pass
def json(self) -> dict:
return self._payload
class TestOllamaProbe:
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
cases = {
"http://remote:11434": "http://remote:11434",
"http://remote:11434/": "http://remote:11434",
"https://ollama.internal": "https://ollama.internal",
"remote:8080": "http://remote:8080",
"remote": "http://remote:11434",
}
for raw, expected in cases.items():
monkeypatch.setenv("OLLAMA_HOST", raw)
assert _ollama_base_url() == expected, raw
monkeypatch.delenv("OLLAMA_HOST", raising=False)
assert _ollama_base_url() == "http://localhost:11434"
def test_probe_false_when_server_down(self, monkeypatch):
def _refuse(url, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(httpx, "get", _refuse)
assert _ollama_up() is False
def test_probe_false_when_model_missing(self, monkeypatch):
monkeypatch.setattr(
httpx,
"get",
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
)
assert _ollama_up() is False
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
seen = {}
def _get(url, timeout):
seen["url"] = url
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
monkeypatch.setattr(httpx, "get", _get)
assert _ollama_up() is True
assert seen["url"] == "http://remote:9999/api/tags"
# ---------------------------------------------------------------------------
# Chunking unit tests (no Ollama required)
# ---------------------------------------------------------------------------
@@ -283,7 +380,7 @@ def indexed_backend():
if not _ollama_up():
pytest.skip("Ollama not reachable")
backend = DenseMemory()
backend = _make_backend()
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
class TestDenseMemoryAPI:
@ollama_required
def test_store_and_delete(self):
backend = DenseMemory()
backend = _make_backend()
doc_id = backend.store("the cat sat on the mat", source="a.txt")
assert backend.count() == 1
hits = backend.retrieve("where is the cat", top_k=1)
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
@ollama_required
def test_empty_retrieve(self):
backend = DenseMemory()
backend = _make_backend()
assert backend.retrieve("anything", top_k=3) == []
@ollama_required
def test_clear(self):
backend = DenseMemory()
backend = _make_backend()
backend.store("foo")
backend.store("bar")
backend.clear()
+48
View File
@@ -537,3 +537,51 @@ class TestGitLogTool:
fn = tool.to_openai_function()
assert fn["type"] == "function"
assert fn["function"]["name"] == "git_log"
# ---------------------------------------------------------------------------
# CLI fallback when the Rust extension is missing
# ---------------------------------------------------------------------------
class TestCliFallbackWhenRustMissing:
"""When ``get_rust_module`` raises ImportError (extension not built,
e.g. a plain pip install), the read-only git tools must fall back to
the git CLI instead of letting the ImportError escape ``execute()``."""
def _patch_no_rust(self):
return patch(
"openjarvis.tools.git_tool.get_rust_module",
side_effect=ImportError("No module named 'openjarvis_rust'"),
)
def test_git_status_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "new_file.txt").write_text("hello")
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "new_file.txt" in result.content
def test_git_diff_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "README.md").write_text("# Modified\n")
with self._patch_no_rust():
result = GitDiffTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "README.md" in result.content
def test_git_log_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
with self._patch_no_rust():
result = GitLogTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "Initial commit" in result.content
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
# Even when the fallback itself fails (not a git repo), the tool
# must return a failed ToolResult rather than raising.
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is False
assert "not a git repository" in result.content
+33
View File
@@ -64,6 +64,39 @@ def test_rejects_drop(store: KnowledgeStore) -> None:
assert not result.success
def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
"""A read-only SELECT must not be rejected because a column/alias merely
contains a write keyword as a substring (e.g. 'created' -> CREATE)."""
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
assert result.success, result.content
assert "Alice" in result.content
def test_allows_keyword_inside_string_literal(store: KnowledgeStore) -> None:
"""A write keyword appearing only inside a string literal must not be
treated as a forbidden statement."""
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(
query="SELECT content FROM knowledge_chunks WHERE content LIKE '%delete%'"
)
assert result.success, result.content
def test_rejects_multi_statement(store: KnowledgeStore) -> None:
"""Multi-statement strings fail with a ToolResult, not an exception."""
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="SELECT 1; VACUUM")
assert not result.success
assert "error" in result.content.lower()
def test_handles_bad_sql(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool