Skip to content
Merged
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/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
qopen_qmlcachegen=$(find /usr/lib/qt6 -type f -name qmlcachegen -print -quit)
test -n "$qopen_qmlcachegen"
qopen_qml_output=$(mktemp -d)
for qopen_qml_file in QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do
for qopen_qml_file in BoundedProcess.qml QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do
"$qopen_qmlcachegen" --only-bytecode \
-o "$qopen_qml_output/$qopen_qml_file.qmlc" "$qopen_qml_file"
done
17 changes: 14 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ patch release, then propagate the released commit back to `uat` and `dev`.
| `QOpen.qml` | Main menu, search, collections, routing, mutations, and status |
| `ResourceEditor.qml` | Type-aware add/edit form and validation feedback |
| `PathPicker.qml` | Embedded bounded local file/directory browser |
| `BoundedProcess.qml` | One-line bounded backend transport and process deadlines |
| `BarWidget.qml` | Optional Omarchy bar entry point |
| `bin/qopen` | Python CLI, JSON API, validation, persistence, and launching |
| `tests/test_backend.py` | Backend, API, safety, and QML regression tests |
Expand Down Expand Up @@ -158,6 +159,8 @@ early feedback, but every write and launch must still be validated by Python.
### QML changes

- Keep process commands as arrays.
- Route every backend response through `BoundedProcess.qml`; do not use
`StdioCollector` or direct catalog `FileView` access.
- Keep all file selection inside `PathPicker.qml`.
- Preserve request ids and stale-response rejection in path browsing.
- Preserve explicit error feedback for clipboard, target checks, and writes.
Expand All @@ -171,11 +174,17 @@ early feedback, but every write and launch must still be validated by Python.
requirement.
- Keep machine API output compact JSON and send human diagnostics to the
appropriate stream.
- Keep catalog, backup and recovery operations descriptor-anchored to one
trusted state directory. Do not reintroduce ordinary pathname reads,
`shutil.copy2`, pathname `chmod`, or a pathname lock file.
- Enforce producer-side byte limits and monotonic deadlines before data reaches
QML. Every captured helper subprocess must have bounded output.
- Create starter resources only when the catalog is missing; preserve existing
catalogs byte-for-byte during reads and upgrades.
- Validate the complete catalog before writes, recovery, and rendering.
- Keep file modes private (`0600`) for configuration, backup, lock, and invalid
recovery snapshots.
- Keep the default state directory private (`0700`) and configuration, backup
and invalid recovery snapshots private (`0600`). Never change the parent
directory permissions of a custom `QOPEN_CONFIG`.
- Use `subprocess` argv lists and bounded timeouts where a subprocess can wait.
- Expand paths only in the backend; do not commit machine-specific absolute
paths.
Expand Down Expand Up @@ -206,7 +215,7 @@ Compile QML with the same strategy as CI:
qopen_qmlcachegen=$(find /usr/lib/qt6 -type f -name qmlcachegen -print -quit)
test -n "$qopen_qmlcachegen"
qopen_qml_output=$(mktemp -d)
for qopen_qml_file in QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do
for qopen_qml_file in BoundedProcess.qml QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do
"$qopen_qmlcachegen" --only-bytecode \
-o "$qopen_qml_output/$qopen_qml_file.qmlc" "$qopen_qml_file"
done
Expand Down Expand Up @@ -246,6 +255,8 @@ At minimum, backend changes must cover:
- command argument round trips, including spaces and quotes;
- unsafe URL, SSH, and control-character rejection;
- permission audit and repair;
- symlink, hard-link, FIFO and non-regular state-file rejection;
- held-lock, oversized-response and concurrent pathname-race behavior;
- recovery with valid and invalid backups;
- project/file browser filtering and stale request behavior.

Expand Down
108 changes: 108 additions & 0 deletions BoundedProcess.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Quickshell.Io
import QtQuick

Item {
id: root

width: 0
height: 0
visible: false

property int timeoutMs: 2000
property int responseLimit: 1310720
readonly property bool running: child.running
readonly property var processId: child.processId

property int activeRequestId: 0
property string responseText: ""
property string protocolError: ""
property bool responseSeen: false
property bool timedOut: false
property bool canceled: false

signal finished(string response, int exitCode, int requestId, string error)

function start(command, requestId) {
if (child.running || !Array.isArray(command) || command.length === 0) return false
root.activeRequestId = Number(requestId || 0)
root.responseText = ""
root.protocolError = ""
root.responseSeen = false
root.timedOut = false
root.canceled = false
deadlineTimer.restart()
child.command = command
child.running = true
return true
}

function cancel() {
root.canceled = true
deadlineTimer.stop()
if (child.running) {
child.signal(15)
killTimer.restart()
}
}

function abortProtocol(message) {
if (root.protocolError) return
root.protocolError = String(message || "Invalid backend response")
if (child.running) {
child.signal(15)
killTimer.restart()
}
}

Process {
id: child

stdout: SplitParser {
splitMarker: "\n"
onRead: function(data) {
if (root.canceled || root.protocolError) return
var line = String(data || "")
if (root.responseSeen) {
root.abortProtocol("Backend returned more than one response")
return
}
if (line.length > root.responseLimit) {
root.abortProtocol("Backend response exceeded its safety limit")
return
}
root.responseText = line
root.responseSeen = true
}
}

onExited: function(exitCode) {
deadlineTimer.stop()
killTimer.stop()
var error = root.protocolError
if (root.timedOut) error = "Backend operation timed out"
else if (root.canceled) error = "Backend operation canceled"
else if (!root.responseSeen) error = "Backend returned no response"
root.finished(root.responseText, exitCode, root.activeRequestId, error)
}
}

Timer {
id: deadlineTimer
interval: root.timeoutMs
onTriggered: {
root.timedOut = true
if (child.running) {
child.signal(15)
killTimer.restart()
}
}
}

Timer {
id: killTimer
interval: 250
onTriggered: {
if (child.running) child.signal(9)
}
}
}
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable user-facing changes to QOpen are documented here.

## [2.5.1] - 2026-08-28

### Security

- Anchored catalog, backup and recovery operations to a trusted, non-symlinked
state-directory descriptor and rejected symlinks, hard links and non-regular
state files.
- Replaced the pathname lock file with a deadline-bound lock on the trusted
state-directory descriptor.
- Added hard limits for catalog reads, API input/output, helper output,
resource counts and directory browsing work.
- Removed direct QML `FileView` catalog access and every unbounded
`StdioCollector`; backend calls now use one-line responses and real process
deadlines with TERM-to-KILL escalation.

### Changed

- Backups now reuse the exact bytes from the validated pre-mutation catalog.
- The default state directory is secured to `0700` by the explicit permission
repair command; custom `QOPEN_CONFIG` parent permissions are never changed.
- Direct raw catalog editing through `qopen --edit` is disabled so writes cannot
bypass validation, backup and atomic replacement.
- Path browsing now bounds both returned entries and total scan work.

### Tests

- Added adversarial coverage for parent and state-file symlinks, FIFOs, hard
links, oversized catalogs, held locks, unbounded clipboard output and
concurrent pathname replacement.

## [2.5.0] - 2026-08-28

### Added
Expand Down
48 changes: 38 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# QOpen 2.4 Design
# QOpen 2.5.1 Design

## Product boundary

Expand All @@ -18,14 +18,21 @@ shell commands well. QOpen owns the resources that do not fit that model:
Omarchy menu / bar
|
v
QOpen.qml <--- watches ---> ~/.config/qopen/config.json
QOpen.qml
|
| bounded argv / one-line JSON
v
BoundedProcess.qml
|
| argv only
v
bin/qopen
|-- validate schema and targets
|-- lock + backup + atomic replace
|-- descriptor-anchored state validation
|-- directory FD lock + backup + atomic replace
|-- bounded reads, responses and helper processes
`-- dispatch through Omarchy launch helpers
|
v
~/.config/qopen/config.json
```

The split is deliberate. QML is responsible for presentation, search and
Expand Down Expand Up @@ -83,14 +90,29 @@ Supported item types are `web`, `file`, `project`, `tui`, `command` and

## Persistence and safety

- A file lock serializes mutations.
- Writes go to a same-directory temporary file and use atomic replacement.
- The previous catalog is copied to `config.json.bak` before mutation.
- Every state-directory component is opened from `/` with directory
descriptors, `O_DIRECTORY` and `O_NOFOLLOW`; the final directory must be
owned by the current user and not writable by another account.
- The trusted state-directory descriptor itself is locked with nonblocking
`flock` and a monotonic deadline; no pathname lock file is needed.
- Configuration and backup files use `O_NOFOLLOW`, `O_NONBLOCK` and descriptor
metadata checks that reject symlinks, hard links and non-regular files.
- Reads are capped at 1 MiB and detect in-place changes before accepting JSON.
- Writes go to a descriptor-relative same-directory temporary file, `fsync`
the file, atomically replace with `src_dir_fd`/`dst_dir_fd`, then `fsync` the
directory.
- The same validated bytes used for a mutation are retained as
`config.json.bak`; the backup is never reopened through an ordinary path.
- Backups are validated before recovery, and the replaced invalid catalog is
retained as a private timestamped snapshot.
- New state files use mode `0600`; `doctor` audits permissions and repair is
explicit.
- The default state directory uses `0700`; state files use `0600`. Custom
`QOPEN_CONFIG` parent permissions are validated but never changed.
- The complete schema is validated before every write.
- API input, producer output, helper output, item count and directory scan work
all have explicit limits.
- QML has no catalog `FileView` and no `StdioCollector`. Backend responses use
one compact JSON line, a second consumer-side limit and real TERM-to-KILL
process deadlines.
- Commands are represented as argument arrays and use lossless POSIX quoting
when displayed for editing.
- File and project paths expand `~` and environment variables only in the
Expand All @@ -100,6 +122,12 @@ Supported item types are `web`, `file`, `project`, `tui`, `command` and
replacing newer navigation state.
- Destructive removal always asks for confirmation.

These controls prevent pathname redirection, special-file blocking, unbounded
retention and writes outside the trusted state directory. They do not claim to
prevent a deliberately malicious process running as the same Unix uid from
directly replacing valid user-owned application code or data; that process
already has the user's filesystem authority.

## Integration

The plugin id is `qopen.launcher` and its manifest exposes `menu` and
Expand Down
Loading