Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ jobs:
APP_PORT: 9080
APP_VERSION: ${{ fromJson(steps.llm2_appinfo.outputs.result).version }}
run: |
APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent-storage/" poetry run python3 main.py > ../logs 2>&1 &
APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent_storage/" poetry run python3 main.py > ../logs 2>&1 &

- name: Register backend
run: |
Expand Down
20 changes: 20 additions & 0 deletions ex_app/lib/all_tools/circles.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
TYPE_APP = 10000


def _validate_circle_id(circle_id: str) -> str:
if '/' in circle_id or '\\' in circle_id:
raise ValueError(f'Invalid circle id: {circle_id!r}')
return circle_id


def _validate_member_id(member_id: str) -> str:
if '/' in member_id or '\\' in member_id:
raise ValueError(f'Invalid member id: {member_id!r}')
return member_id


async def get_tools(nc: AsyncNextcloudApp):
@tool
@safe_tool
Expand All @@ -34,6 +46,7 @@ async def get_circle_details(circle_id: str):
:param circle_id: the id of the circle (obtainable via list_circles)
:return: complete circle information including members
"""
_validate_circle_id(circle_id)
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}'))

@tool
Expand All @@ -44,6 +57,7 @@ async def list_circle_members(circle_id: str):
:param circle_id: the id of the circle (obtainable via list_circles)
:return: list of members with their id, display name, type, and level
"""
_validate_circle_id(circle_id)
circle_members = await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members')
return json.dumps(circle_members)

Expand Down Expand Up @@ -76,6 +90,7 @@ async def add_member_to_circle(circle_id: str, member_id: str, member_type: int
:param member_type: type of member as integer constant - 1=user, 2=group, 4=mail, 8=contact, 16=circle (default: 1 for user)
:return: the added member information
"""
_validate_circle_id(circle_id)
payload = {
"members": [{
'id': member_id,
Expand All @@ -93,6 +108,8 @@ async def remove_member_from_circle(circle_id: str, member_id: str):
:param member_id: the id of the member to remove (obtainable via list_circle_members)
:return:
"""
_validate_circle_id(circle_id)
_validate_member_id(member_id)
return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/{member_id}'))

@tool
Expand All @@ -105,6 +122,7 @@ async def update_circle(circle_id: str, name: Optional[str] = None, description:
:param description: new description
:return:
"""
_validate_circle_id(circle_id)
if name is not None:
await nc.ocs('PUT', f'/ocs/v2.php/apps/circles/circles/{circle_id}/name', json={'value': name})
if description is not None:
Expand All @@ -119,6 +137,7 @@ async def delete_circle(circle_id: str):
:param circle_id: the id of the circle to delete (obtainable via list_circles)
:return:
"""
_validate_circle_id(circle_id)
await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}')

@tool
Expand All @@ -131,6 +150,7 @@ async def share_with_circle(path: str, circle_id: str, permissions: int = 19):
:param permissions: permissions bitmask - 1=read, 2=update, 4=create, 8=delete, 16=share. Default is 19
:return: the created share
"""
_validate_circle_id(circle_id)
return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={
'path': path,
'shareType': 7, # 7 = circle
Expand Down
3 changes: 3 additions & 0 deletions ex_app/lib/all_tools/cookbook.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import urllib.parse
from typing import Optional
from langchain_core.tools import tool
from nc_py_api import AsyncNextcloudApp
Expand Down Expand Up @@ -35,6 +36,8 @@ async def search_recipes(search_term: str):
:param search_term: text to search for in recipe names and descriptions
:return: list of matching recipes
"""
if '/' in search_term or '\\' in search_term:
raise ValueError("Search term cannot contain slashes.")
response = await nc._session._create_adapter().request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/cookbook/api/v1/search/{search_term}", headers={
"Content-Type": "application/json",
"OCS-APIREQUEST": "true",
Expand Down
21 changes: 20 additions & 1 deletion ex_app/lib/all_tools/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool


def _validate_path(path: str) -> str:
"""Reject path traversal attempts in a user-supplied file path."""
if not isinstance(path, str):
raise ValueError(f'Invalid path: {path!r}')
for segment in path.split('/'):
if segment in ('.', '..'):
raise ValueError(f'Invalid path (traversal not allowed): {path!r}')
return path


async def get_tools(nc: AsyncNextcloudApp):

@tool
Expand All @@ -20,6 +30,7 @@ async def get_file_content(file_path: str):
:return:
"""

_validate_path(file_path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{file_path}", headers={
Expand Down Expand Up @@ -70,6 +81,7 @@ async def create_public_sharing_link(path: str):
:return:
"""

_validate_path(path)
response = await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={
'path': path,
'shareType': 3,
Expand All @@ -86,6 +98,7 @@ async def upload_file(path: str, content: str):
:param content: the text content to write to the file
:return: success confirmation
"""
_validate_path(path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('PUT', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={
Expand All @@ -102,6 +115,7 @@ async def create_folder(path: str):
:param path: the path of the folder to create (e.g., "/Documents/NewFolder")
:return: success confirmation
"""
_validate_path(path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('MKCOL', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={
Expand All @@ -119,6 +133,8 @@ async def move_file(source_path: str, destination_path: str):
:param destination_path: the new path for the file/folder
:return: success confirmation
"""
_validate_path(source_path)
_validate_path(destination_path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('MOVE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{source_path}", headers={
Expand All @@ -136,6 +152,8 @@ async def copy_file(source_path: str, destination_path: str):
:param destination_path: the destination path
:return: success confirmation
"""
_validate_path(source_path)
_validate_path(destination_path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('COPY', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{source_path}", headers={
Expand All @@ -152,6 +170,7 @@ async def delete_file(path: str):
:param path: the path of the file/folder to delete
:return: success confirmation
"""
_validate_path(path)
user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"]

response = await nc._session._create_adapter(True).request('DELETE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={
Expand All @@ -176,4 +195,4 @@ def get_category_name():
return "Files"

async def is_available(nc: AsyncNextcloudApp):
return True
return True
14 changes: 9 additions & 5 deletions ex_app/lib/all_tools/here.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import typing
import datetime
import urllib.parse

import niquests
from langchain_core.tools import tool
Expand All @@ -28,11 +27,16 @@ async def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon
"""

if departure_time is None:
departure_time = urllib.parse.quote_plus(datetime.datetime.now(datetime.UTC).isoformat())
departure_time = datetime.datetime.now(datetime.UTC).isoformat()
api_key = await nc.appconfig_ex.get_value('here_api')
res = await niquests.async_api.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin='
+ origin_lat + ',' + origin_lon + '&destination=' + destination_lat + ',' + destination_lon
+ '&alternatives=' + str(routes-1) + '&departureTime=' + departure_time + '&apikey=' + api_key)
res = await niquests.async_api.get('https://transit.hereapi.com/v8/routes', params={
'transportMode': 'car',
'origin': f'{origin_lat},{origin_lon}',
'destination': f'{destination_lat},{destination_lon}',
'alternatives': str(routes - 1),
'departureTime': departure_time,
'apikey': api_key,
})
json = res.json()
return json

Expand Down
23 changes: 18 additions & 5 deletions ex_app/lib/all_tools/openstreetmap.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import urllib.parse

import niquests
from langchain_core.tools import tool
from nc_py_api import AsyncNextcloudApp
Expand Down Expand Up @@ -45,12 +47,23 @@ async def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destinat
profile_num = "1"
case "routed-foot":
profile_num = "2"
case _:
case _:
profile = "routed-foot"
profile_num = "2"
url = f'https://routing.openstreetmap.de/{profile}/route/v1/driving/{origin_lon},{origin_lat};{destination_lon},{destination_lat}?overview=false&steps=true'
map_url = f' https://routing.openstreetmap.de/?loc={origin_lat}%2C{origin_lon}&loc={destination_lat}%2C{destination_lon}&srv={profile_num}'
res = await niquests.async_api.get(url)
profile_num = "2"
# URL-encode each coordinate so untrusted values can't break out of the
# path segment (commas/semicolons are intentionally kept as separators
# expected by the OSRM route endpoint).
o_lat = urllib.parse.quote(origin_lat, safe='')
o_lon = urllib.parse.quote(origin_lon, safe='')
d_lat = urllib.parse.quote(destination_lat, safe='')
d_lon = urllib.parse.quote(destination_lon, safe='')
url = f'https://routing.openstreetmap.de/{profile}/route/v1/driving/{o_lon},{o_lat};{d_lon},{d_lat}'
map_url = 'https://routing.openstreetmap.de/?' + urllib.parse.urlencode([
('loc', f'{origin_lat},{origin_lon}'),
('loc', f'{destination_lat},{destination_lon}'),
('srv', profile_num),
])
res = await niquests.async_api.get(url, params={'overview': 'false', 'steps': 'true'})
json = res.json()
return {'route_json_description': json, 'map_url': map_url}

Expand Down
Loading