Skip to content
Draft
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
22 changes: 14 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run: |
mkdir -p build
xcrun swiftc -O -parse-as-library src/*.swift -o build/desktidy-sort
codesign -s - -i com.desktidy.sort build/desktidy-sort || true
codesign -s - -i com.desktidy.sort build/desktidy-sort

- name: Self-test (deterministic safety checks)
run: ./build/desktidy-sort --self-test
Expand Down Expand Up @@ -76,10 +76,11 @@ jobs:
- name: R1A effective-state gates (matrix, parity, fail-closed UI, read-only)
run: ./build/desktidy-sort --state-test

- name: R1A app build + headless smokes (no GUI session needed)
- name: R1A app build + hermetic fixture smokes
run: |
chmod +x scripts/smoke-app.sh scripts/test-smoke-isolation.sh scripts/test-cli-status.sh
./scripts/test-smoke-isolation.sh
./scripts/build-app.sh build
# conflict fixture: the app binary must fail closed, never claim running
AG=$(mktemp -d); TG=$(mktemp -d); AP=$(mktemp -d)
PROG=$(mktemp -d)/mover; printf '#!/bin/sh\n' > "$PROG"
/usr/bin/python3 - "$AG" "$TG" "$PROG" <<'PYEOF2'
Expand All @@ -90,12 +91,17 @@ jobs:
with open(os.path.join(ag, 'state.json'), 'w') as f:
json.dump({'com.example.fixture-mover': 'running'}, f)
PYEOF2
OUT=$(DESKTIDY_AGENTS_DIR="$AG" DESKTIDY_TARGET_DIR="$TG" DESKTIDY_APP_DIR="$AP" DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1)
echo "$OUT"; test "$OUT" = "SMOKE overall=foreignConflict"
# clean fixture: pausedNotLoaded
DESKTIDY_AGENTS_DIR="$AG" DESKTIDY_TARGET_DIR="$TG" DESKTIDY_APP_DIR="$AP" \
DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" EXPECTED_OVERALL=foreignConflict \
./scripts/smoke-app.sh ./build/DeskTidy.app/Contents/MacOS/DeskTidy
AG2=$(mktemp -d); TG2=$(mktemp -d); AP2=$(mktemp -d)
OUT2=$(DESKTIDY_AGENTS_DIR="$AG2" DESKTIDY_TARGET_DIR="$TG2" DESKTIDY_APP_DIR="$AP2" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1)
echo "$OUT2"; test "$OUT2" = "SMOKE overall=pausedNotLoaded"
printf '%s\n' '{}' > "$AG2/state.json"
DESKTIDY_AGENTS_DIR="$AG2" DESKTIDY_TARGET_DIR="$TG2" DESKTIDY_APP_DIR="$AP2" \
DESKTIDY_LAUNCHD_STATE_FILE="$AG2/state.json" EXPECTED_OVERALL=pausedNotLoaded \
./scripts/smoke-app.sh ./build/DeskTidy.app/Contents/MacOS/DeskTidy

- name: Public CLI status consumes shared effective-state
run: ./scripts/test-cli-status.sh ./build/desktidy-sort

- name: R1A read-only confinement grep (app sources contain no mutation calls)
run: |
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased (branch r1b/phase0-unified-truth — stacked on R1A, non-final)

- **R1B Phase 0 (no live service migration):** one target resolver, one
app-support/receipt path helper, public `desktidy status` consumes
`--effective-state`, requested-but-invalid launchd fixtures fail closed,
product identity is centralized without widening the accepted self set.
Native `config.json` is a reader/model only — nothing writes it yet.
Schema-1 native config is parsed from raw UTF-8 with a strict object
parser; duplicate keys (including escaped-equivalent spellings) fail
closed and do not fall through.

## Unreleased (branch r1a/public-trust-surface)

- **Experimental menu-bar app (read-only trust surface):** build from source
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,21 @@ records every final path; subfolders (including `Inbox/`) are never re-sorted;

## Roadmap

- Menu-bar app: an **experimental read-only status surface exists** (build it
from source with `scripts/build-app.sh` — it shows the watched folder,
movement authority, and receipt-ledger health, and refuses to claim
"running" under any conflict or ambiguity). Pause/resume, activity feed,
and notifications are still to come; it is not shipped or packaged.
- Menu-bar app: an **experimental read-only status surface exists** on the
R1A/R1B source branches (build it with `scripts/build-app.sh`). It is not
packaged, not in the Homebrew formula, and not a public release. Pause/resume,
activity feed, and notifications are still to come.
- Per-folder rules and user-defined categories via a JSON config (no rebuild).
- Suggestion previews you can approve in one click — per the [ML authority policy](docs/ML_AUTHORITY_POLICY.md), model output never moves files on its own; approval stays human.
- Homebrew tap for one-line install.
- Homebrew tap (`anubisquantumcipher/tap/desktidy`) already exists; the
published formula remains **v1.1.2** and does **not** install R0 receipts,
R1A, or R1B. This source tree is experimental and unmerged.

## Contributing

Issues and PRs welcome. The whole thing is ~650 lines of Swift plus three small shell scripts — easy to read and hack on. Run `desktidy-sort --self-test` after changes.
Issues and PRs welcome. The sorter, receipts, and tests are a small Swift
tree plus a few shell scripts — read `src/` rather than trusting a line
count. Run `desktidy-sort --self-test` after changes.

## License

Expand Down
17 changes: 3 additions & 14 deletions app/DeskTidyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,25 +149,14 @@ struct ContentView: View {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}

private func receiptsDir() -> URL {
let env = ProcessInfo.processInfo.environment
let base: URL
if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty {
base = URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true)
}
return base.appendingPathComponent("receipts", isDirectory: true)
}
private func receiptsDir() -> URL { DeskTidyPaths.receiptsDirectory() }

private func receiptsExist() -> Bool {
FileManager.default.fileExists(atPath: receiptsDir().appendingPathComponent("ledger.jsonl").path)
FileManager.default.fileExists(atPath: DeskTidyPaths.ledgerURL().path)
}

private func revealReceipts() {
NSWorkspace.shared.activateFileViewerSelecting(
[receiptsDir().appendingPathComponent("ledger.jsonl")])
NSWorkspace.shared.activateFileViewerSelecting([DeskTidyPaths.ledgerURL()])
}

private func copyDiagnostic() {
Expand Down
45 changes: 45 additions & 0 deletions docs/R1B_SERVICE_IDENTITY_PROPOSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# R1B Service Identity Proposal — Phase 1 input (NOT APPLIED)

_Phase 0 proposal only. The accepted self set remains `com.desktidy.sort` and
`com.desktidy.notify`. This document does not widen trust._

## Current accepted identity (Phase 0, executable)

| Role | Label | Expected program basename |
|---|---|---|
| sorter | `com.desktidy.sort` | `desktidy-sort` |
| notifier | `com.desktidy.notify` | `desktidy-notify` |
| menu-bar app (not an agent) | bundle id `com.desktidy.app` | `DeskTidy` |

A label from this set with a **contradictory existing executable** (basename
outside the expected set) is **not** self. A future or unloaded label such as
`com.desktidy.app.sort` is **not** self.

## What Phase 1 must add — only after sacrificial observation

`SMAppService.agent(plistName:)` typically registers a bundle-scoped agent
whose launchd label is not the CLI pair above. Until that label is observed
on a sacrificial, non-live root, it must not be added to `ProductIdentity.selfLabels`.

Proposed binding to confirm in the authorized Phase 1 observation:

1. **Label** — exact string printed by `launchctl print` after
`SMAppService.agent(plistName:).register()` (likely
`com.desktidy.app.<plist-stem>` or the embedded plist's `Label`).
2. **Program** — the bundle executable or a `BundleProgram` relative path
inside `Contents/Library/LaunchAgents`.
3. **Bundle id** — `com.desktidy.app`.
4. **Coexistence** — if a legacy `com.desktidy.sort` plist and the new app
label both watch the same root, EffectiveState must be `ambiguous` /
refuse. Never treat dual self-presence as healthy.
5. **Atomic catalog update** — `selfLabels`, expected basenames, and the
target resolver's product-plist name must change in the same commit as
registration code. A half-updated catalog is an accept-condition change
and is prohibited.

## Explicitly not authorized by Phase 0

- Adding any SMAppService/app-agent label to the accepted self set
- Treating label-only match as self when program evidence contradicts it
(Phase 0 already fails closed on that evidence)
- Registering, unregistering, or launching a new agent
63 changes: 63 additions & 0 deletions docs/evidence/R1B_PHASE0_ABA_DUPLICATE_JSON_KEYS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# R1B Phase 0 A→B→A — duplicate native-config keys must fail closed

Semantic mutation of `src/NativeConfigParser.swift`: after JSON escape
decoding, a repeated object key is ignored instead of rejected.

No live Desktop paths, no private file contents.

## A (green)

- file: `src/NativeConfigParser.swift`
- SHA-256: `00efb21e99cce717f73fe812c879599b7087449a9b1a137bf037a23b96a02e5d`
- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-dupe-aba/desktidy-sort && /tmp/desktidy-r1b-dupe-aba/desktidy-sort --state-test`
- exit: `0`
- excerpt:

```
PASS D01 duplicate target keys (different values) → invalid
PASS D04 escaped-equivalent duplicate target key → invalid
PASS D08 engine refuses duplicate-key config (exit 3, no move)
R1A GATES: 63 passed, 0 failed
```

## B (duplicate rejection disabled)

- SHA-256: `713477b521b5173073bfe0bde57dc5a7cf3a05d0079efaa60519428b1217b644`
- rebuild: previous binary deleted, then `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-dupe-aba/desktidy-sort`
- command: `/tmp/desktidy-r1b-dupe-aba/desktidy-sort --state-test`
- exit: `1`
- failing IDs:

```
FAIL D01 duplicate target keys (different values) → invalid — got pausedNotLoaded res=resolved src=nativeConfig target=.../target-b-...
FAIL D02 duplicate target keys (identical values) → invalid — got pausedNotLoaded res=resolved
FAIL D03 duplicate schema keys → invalid — got pausedNotLoaded
FAIL D04 escaped-equivalent duplicate target key → invalid — got pausedNotLoaded target=.../target-b-...
FAIL D08 engine refuses duplicate-key config (exit 3, no move) — exit=0 stayed=true
R1A GATES: 58 passed, 5 failed
```

D01/D04's intended reason is last-wins resolution (`res=resolved`, `nativeConfig`)
instead of `invalid`. D08's `exit=0` shows the engine accepted a selected
duplicate-key config and did not refuse movement.

Diff (B vs A):

```diff
if seen.contains(key) {
- return .failed("native config has a duplicate key")
- }
+ // B-MUTATION: ignore duplicate keys after escape decoding
+ } else {
seen.insert(key)
+ }
```

## Restore (A bytes)

- `cp` of the A snapshot over `src/NativeConfigParser.swift`
- SHA-256: `00efb21e99cce717f73fe812c879599b7087449a9b1a137bf037a23b96a02e5d` (equals A)
- rebuild after deleting the B binary
- `--state-test` exit 0 — `R1A GATES: 63 passed, 0 failed`; D01 PASS
- `--self-test` exit 0 — `PASS: 17 deterministic safety checks`
- `--r0-test` exit 0 — `R0 CONTROLS: 31 passed, 0 failed`
66 changes: 66 additions & 0 deletions docs/evidence/R1B_PHASE0_ABA_TARGET_FAIL_CLOSED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# R1B Phase 0 A→B→A — malformed native config must not fall through

Semantic mutation of `src/TargetResolver.swift`: invalid native `config.json`
falls through to env/plist/default instead of failing closed.

No live Desktop paths, no private file contents.

## A (green)

- file: `src/TargetResolver.swift`
- SHA-256: `f84821ab5a8b0a6fc302f9d695bb53fd7c88a6d5d7ae5762e509f33d6111c619`
- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase0-aba/desktidy-sort && /tmp/desktidy-r1b-phase0-aba/desktidy-sort --state-test`
- exit: `0`
- excerpt:

```
PASS T01 malformed native config refuses instead of env/default fallback
R1A GATES: 55 passed, 0 failed
```

## B (semantic fail-open)

Mutation: if native config exists but `readNativeConfig` fails, `break` and
continue to plist/env/default.

- SHA-256: `aa6c1e278563c177b1580a1b6779527eaea219d73f125da958268cb6267f2436`
- rebuild: previous binary deleted, then `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase0-aba/desktidy-sort`
- command: `/tmp/desktidy-r1b-phase0-aba/desktidy-sort --state-test`
- exit: `1`
- failing IDs:

```
FAIL T01 malformed native config refuses instead of env/default fallback — got pausedNotLoaded res=resolved: no conflicting authority, and DeskTidy's agent is not loaded
FAIL T06 empty/wrong-type native target refuses — empty=pausedNotLoaded wrong=pausedNotLoaded
FAIL T07 unreadable native config refuses — got pausedNotLoaded: no conflicting authority, and DeskTidy's agent is not loaded
FAIL T10 engine refuses ambiguous target (exit 3, no move) — exit=0 stayed=false
R1A GATES: 51 passed, 4 failed
```

T01's intended reason is env fallback (`pausedNotLoaded` / `res=resolved`).
T10's `exit=0 stayed=false` shows the engine moved the fixture witness after
the invalid config was ignored.

Diff (B vs A):

```diff
if fm.fileExists(atPath: configURL.path) {
- return finish(readNativeConfig(configURL, fm: fm), source: .nativeConfig, fm: fm)
+ let parsed = readNativeConfig(configURL, fm: fm)
+ switch parsed {
+ case .ok:
+ return finish(parsed, source: .nativeConfig, fm: fm)
+ case .failed:
+ break // B-MUTATION: fall through instead of fail-closed
+ }
}
```

## Restore (A bytes)

- `cp` of the A snapshot over `src/TargetResolver.swift`
- SHA-256: `f84821ab5a8b0a6fc302f9d695bb53fd7c88a6d5d7ae5762e509f33d6111c619` (equals A)
- rebuild after deleting the B binary
- `--state-test` exit 0 — `R1A GATES: 55 passed, 0 failed`; T01 PASS
- `--self-test` exit 0 — `PASS: 17 deterministic safety checks`
- `--r0-test` exit 0 — `R0 CONTROLS: 31 passed, 0 failed`
7 changes: 5 additions & 2 deletions scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
xcrun swiftc -O -parse-as-library \
-target "arm64-apple-macosx$MACOS_MIN" \
"$REPO/src/Config.swift" \
"$REPO/src/Paths.swift" \
"$REPO/src/TargetResolver.swift" \
"$REPO/src/NativeConfigParser.swift" \
"$REPO/src/ProductIdentity.swift" \
"$REPO/src/Authority.swift" \
"$REPO/src/Receipts.swift" \
"$REPO/src/EffectiveState.swift" \
Expand Down Expand Up @@ -46,6 +50,5 @@ cat > "$APP/Contents/Info.plist" <<PLIST
</plist>
PLIST

codesign -s - -i com.desktidy.app --force "$APP" >/dev/null 2>&1 || true
codesign -s - -i com.desktidy.app --force "$APP"
echo "built: $APP"
"$APP/Contents/MacOS/DeskTidy" --smoke 2>/dev/null || true
44 changes: 44 additions & 0 deletions scripts/smoke-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/bash
# Hermetic headless smoke of the menu-bar binary. Requires isolated fixtures.
# Never probes the live Desktop or live launchd.
set -euo pipefail

need() {
local n="$1"
if [ -z "${!n:-}" ]; then
echo "smoke: missing required fixture variable $n" >&2
exit 2
fi
}

need DESKTIDY_AGENTS_DIR
need DESKTIDY_TARGET_DIR
need DESKTIDY_APP_DIR
need DESKTIDY_LAUNCHD_STATE_FILE
need EXPECTED_OVERALL

APP_BIN="${1:-${DESKTIDY_APP_BIN:-}}"
if [ -z "$APP_BIN" ] || [ ! -x "$APP_BIN" ]; then
echo "smoke: app binary required as \$1 or DESKTIDY_APP_BIN" >&2
exit 2
fi

[ -d "$DESKTIDY_AGENTS_DIR" ] || { echo "smoke: agents dir does not exist" >&2; exit 2; }
[ -d "$DESKTIDY_TARGET_DIR" ] || { echo "smoke: target dir does not exist" >&2; exit 2; }
[ -d "$DESKTIDY_APP_DIR" ] || { echo "smoke: app-support dir does not exist" >&2; exit 2; }
[ -f "$DESKTIDY_LAUNCHD_STATE_FILE" ] || { echo "smoke: launchd fixture file is absent" >&2; exit 2; }

/usr/bin/python3 -c 'import json,sys; json.load(open(sys.argv[1]))' \
"$DESKTIDY_LAUNCHD_STATE_FILE" || {
echo "smoke: launchd fixture is not valid JSON" >&2
exit 2
}

OUT="$("$APP_BIN" --smoke)"
echo "$OUT"
last="$(printf '%s\n' "$OUT" | tail -1)"
expected="SMOKE overall=$EXPECTED_OVERALL"
if [ "$last" != "$expected" ]; then
echo "smoke: expected $expected, got $last" >&2
exit 1
fi
Loading
Loading