mac-upkeep 3.0.2__tar.gz → 3.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. mac_upkeep-3.0.4/.github/dependabot.yml +20 -0
  2. mac_upkeep-3.0.4/.github/workflows/pr-checks.yml +42 -0
  3. mac_upkeep-3.0.4/.github/workflows/release.yml +176 -0
  4. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/.github/workflows/test.yml +2 -2
  5. mac_upkeep-3.0.4/.release-please-manifest.json +3 -0
  6. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CHANGELOG.md +19 -0
  7. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CLAUDE.md +93 -3
  8. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/PKG-INFO +65 -1
  9. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/README.md +64 -0
  10. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/pyproject.toml +1 -1
  11. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/cli.py +40 -8
  12. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/config.py +120 -25
  13. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/git_sync.py +85 -6
  14. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/output.py +65 -8
  15. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/tasks.py +124 -53
  16. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/conftest.py +12 -0
  17. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_cli.py +68 -0
  18. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_config.py +109 -1
  19. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_git_sync.py +193 -6
  20. mac_upkeep-3.0.4/tests/test_output.py +251 -0
  21. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_tasks.py +127 -2
  22. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/uv.lock +1 -1
  23. mac_upkeep-3.0.2/.github/workflows/release.yml +0 -91
  24. mac_upkeep-3.0.2/.release-please-manifest.json +0 -3
  25. mac_upkeep-3.0.2/tests/test_output.py +0 -131
  26. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/.gitignore +0 -0
  27. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CONTRIBUTING.md +0 -0
  28. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/LICENSE +0 -0
  29. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/demo/demo.gif +0 -0
  30. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/demo/record.sh +0 -0
  31. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/docs/reusable-patterns.md +0 -0
  32. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/llms.txt +0 -0
  33. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/release-please-config.json +0 -0
  34. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/__init__.py +0 -0
  35. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/defaults.toml +0 -0
  36. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/editor_cache.py +0 -0
  37. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/notify.py +0 -0
  38. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/py.typed +0 -0
  39. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/__init__.py +0 -0
  40. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_editor_cache.py +0 -0
  41. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_isolation.py +0 -0
  42. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_notify.py +0 -0
  43. {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_retry_backoff.py +0 -0
@@ -0,0 +1,20 @@
1
+ # Actions are pinned to full commit SHAs, which is only safe if the pins are
2
+ # maintained rather than frozen. Dependabot updates a SHA pin and rewrites the
3
+ # trailing `# vX.Y.Z` comment alongside it.
4
+ version: 2
5
+ updates:
6
+ - package-ecosystem: github-actions
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ commit-message:
11
+ prefix: ci
12
+ # NOTE: `ci` DOES cut a release in this repo - v3.0.2 was produced by a single
13
+ # `ci:` commit. So each grouped Dependabot PR will open a release-please PR.
14
+ # That is triage, not breakage: close a release PR whose only contents are
15
+ # action-pin bumps, exactly as for any other ci/docs/chore-only release PR.
16
+ # Weekly is kept deliberately - stale SHA pins are the risk this file exists
17
+ # to manage, and pin freshness matters more than release-PR noise.
18
+ groups:
19
+ actions:
20
+ patterns: ["*"]
@@ -0,0 +1,42 @@
1
+ # Reject a `Release-As:` trailer at the point it would enter the repository.
2
+ #
3
+ # squash_merge_commit_message is COMMIT_MESSAGES, so a squashed PR's body is the
4
+ # contributor's own commit messages. release-please parses a `Release-As:` footer
5
+ # and short-circuits all version derivation, so a merged benign-looking PR can
6
+ # dictate the published version -- and PyPI never permits version reuse.
7
+ #
8
+ # An anchored semver check in release.yml is NOT sufficient on its own: the
9
+ # payload `Release-As: 99.0.0` yields the perfectly well-formed tag v99.0.0.
10
+ # release.yml's verify-version job catches the jump; this catches the trailer.
11
+ name: pr-checks
12
+ on:
13
+ pull_request:
14
+ branches: [main]
15
+ permissions:
16
+ contents: read
17
+ jobs:
18
+ no-release-as:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
+ with:
23
+ fetch-depth: 0
24
+ - name: Reject Release-As trailers
25
+ env:
26
+ BASE: ${{ github.event.pull_request.base.sha }}
27
+ HEAD: ${{ github.event.pull_request.head.sha }}
28
+ # squash_merge_commit_title is COMMIT_OR_PR_TITLE, so for a multi-commit PR
29
+ # the squashed subject comes from the PR title -- which is not in git log.
30
+ PR_TITLE: ${{ github.event.pull_request.title }}
31
+ PR_BODY: ${{ github.event.pull_request.body }}
32
+ run: |
33
+ set -euo pipefail
34
+ {
35
+ git log --format='%B' "$BASE..$HEAD"
36
+ printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY"
37
+ } > /tmp/release-as-scan.txt
38
+ if grep -qiE '^[[:space:]]*Release-As:' /tmp/release-as-scan.txt; then
39
+ echo "::error::A Release-As trailer overrides release-please version derivation. Remove it."
40
+ exit 1
41
+ fi
42
+ echo "No Release-As trailer found."
@@ -0,0 +1,176 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ contents: write
7
+ pull-requests: write
8
+ jobs:
9
+ release-please:
10
+ runs-on: ubuntu-latest
11
+ outputs:
12
+ release_created: ${{ steps.release.outputs.release_created }}
13
+ tag_name: ${{ steps.release.outputs.tag_name }}
14
+ steps:
15
+ - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
16
+ id: app-token
17
+ with:
18
+ client-id: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
19
+ private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
20
+ - id: release
21
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
22
+ with:
23
+ token: ${{ steps.app-token.outputs.token }}
24
+ config-file: release-please-config.json
25
+
26
+ - name: Checkout release PR branch
27
+ if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
28
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ with:
30
+ token: ${{ steps.app-token.outputs.token }}
31
+ ref: release-please--branches--main
32
+
33
+ - name: Setup uv
34
+ if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
35
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
36
+
37
+ - name: Update uv.lock in release PR
38
+ if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
39
+ env:
40
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
41
+ APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
42
+ run: |
43
+ uv lock
44
+ if git diff --quiet uv.lock; then
45
+ echo "uv.lock already up to date"
46
+ else
47
+ BOT_USER_ID=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
48
+ git config user.name "${APP_SLUG}[bot]"
49
+ git config user.email "${BOT_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
50
+ git add uv.lock
51
+ git commit -m "chore: update uv.lock"
52
+ git push
53
+ fi
54
+
55
+ # Gate BOTH release_created consumers. release-please honours a `Release-As:`
56
+ # footer and short-circuits all version derivation, and squash merges put a
57
+ # contributor's own commit messages into the commit that lands on main -- so a
58
+ # merged PR can dictate the published version. An anchored semver regex alone
59
+ # does NOT help: the payload `Release-As: 99.0.0` produces the perfectly
60
+ # well-formed tag v99.0.0. The size of the jump is what gives it away.
61
+ verify-version:
62
+ needs: release-please
63
+ if: ${{ needs.release-please.outputs.release_created == 'true' }}
64
+ runs-on: ubuntu-latest
65
+ permissions:
66
+ contents: read
67
+ steps:
68
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69
+ with:
70
+ fetch-depth: 0
71
+ - name: Refuse implausible version jumps
72
+ env:
73
+ TAG: ${{ needs.release-please.outputs.tag_name }}
74
+ run: |
75
+ set -euo pipefail
76
+ [[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || {
77
+ echo "::error::Refusing malformed tag: $TAG"; exit 1; }
78
+ new_major=${BASH_REMATCH[1]}; new_minor=${BASH_REMATCH[2]}; new_patch=${BASH_REMATCH[3]}
79
+
80
+ # Compare against the highest EXISTING release tag, not the manifest:
81
+ # by the time this job runs the release PR has merged, so
82
+ # .release-please-manifest.json already holds the new version and would
83
+ # always compare equal.
84
+ prev=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
85
+ | grep -v -x -F "$TAG" | head -n1 || true)
86
+ if [[ -z "$prev" ]]; then
87
+ echo "No previous release tag; skipping jump check."
88
+ exit 0
89
+ fi
90
+ [[ "$prev" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || {
91
+ echo "::error::Unparseable previous tag: $prev"; exit 1; }
92
+ cur_major=${BASH_REMATCH[1]}; cur_minor=${BASH_REMATCH[2]}; cur_patch=${BASH_REMATCH[3]}
93
+
94
+ # Allowlist the three bumps release-please can actually produce, rather than
95
+ # bounding only upward major/minor movement -- a one-sided check accepts any
96
+ # version DECREASE and any patch jump, so `Release-As: 3.0.99` sails through.
97
+ if (( new_major == cur_major + 1 && new_minor == 0 && new_patch == 0 )); then :
98
+ elif (( new_major == cur_major && new_minor == cur_minor + 1 && new_patch == 0 )); then :
99
+ elif (( new_major == cur_major && new_minor == cur_minor \
100
+ && new_patch == cur_patch + 1 )); then :
101
+ else
102
+ echo "::error::Implausible bump $prev -> $TAG; a Release-As trailer may have been merged."
103
+ echo "::error::Expected one of: v$((cur_major + 1)).0.0, v${cur_major}.$((cur_minor + 1)).0, v${cur_major}.${cur_minor}.$((cur_patch + 1))"
104
+ exit 1
105
+ fi
106
+ echo "$prev -> $TAG is a normal bump."
107
+
108
+ # Build is separated from publish so that anything injected into the build
109
+ # environment cannot reach the OIDC credentials. `[build-system] requires` is
110
+ # unbounded and uv.lock does not cover build requirements at all, so six
111
+ # packages resolve fresh from the live index and then execute here. PyPA's own
112
+ # guidance: "only set the id-token: write permission in the job that does
113
+ # publishing... try to separate building from publishing".
114
+ build:
115
+ needs: [release-please, verify-version]
116
+ if: ${{ needs.release-please.outputs.release_created == 'true' }}
117
+ runs-on: ubuntu-latest
118
+ permissions:
119
+ contents: read
120
+ steps:
121
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
122
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
123
+ - run: uv build
124
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
125
+ with:
126
+ name: dist
127
+ path: dist/
128
+ if-no-files-found: error
129
+
130
+ pypi-publish:
131
+ needs: build
132
+ runs-on: ubuntu-latest
133
+ environment: pypi
134
+ permissions:
135
+ id-token: write
136
+ steps:
137
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
138
+ with:
139
+ name: dist
140
+ path: dist/
141
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
142
+
143
+ bump-tap:
144
+ needs: [release-please, verify-version]
145
+ if: ${{ needs.release-please.outputs.release_created == 'true' }}
146
+ runs-on: ubuntu-latest
147
+ steps:
148
+ - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
149
+ id: app-token
150
+ with:
151
+ client-id: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
152
+ private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
153
+ repositories: homebrew-tap
154
+ # Build the payload with jq rather than string interpolation, so payload
155
+ # STRUCTURE can never depend on tag content. A double quote is a legal git
156
+ # ref character, and an interpolated tag containing one produced malformed
157
+ # JSON that silently broke the tap bump.
158
+ - id: payload
159
+ env:
160
+ TAG: ${{ needs.release-please.outputs.tag_name }}
161
+ run: |
162
+ set -euo pipefail
163
+ json=$(jq -cn --arg tag "$TAG" '{
164
+ formula: "mac-upkeep",
165
+ version: $tag,
166
+ type: "python",
167
+ url: ("https://github.com/calvindotsg/mac-upkeep/archive/refs/tags/" + $tag + ".tar.gz")
168
+ }')
169
+ echo "json=$json" >> "$GITHUB_OUTPUT"
170
+ - name: Dispatch formula update
171
+ uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
172
+ with:
173
+ token: ${{ steps.app-token.outputs.token }}
174
+ repository: calvindotsg/homebrew-tap
175
+ event-type: formula-update
176
+ client-payload: ${{ steps.payload.outputs.json }}
@@ -11,8 +11,8 @@ jobs:
11
11
  matrix:
12
12
  python-version: ["3.11", "3.12", "3.13"]
13
13
  steps:
14
- - uses: actions/checkout@v7
15
- - uses: astral-sh/setup-uv@v10.0.1
14
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
16
16
  with:
17
17
  python-version: ${{ matrix.python-version }}
18
18
  - run: uv lock --check
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "3.0.4"
3
+ }
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.0.4](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.3...v3.0.4) (2026-08-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * close six findings from security audit run-1 ([#53](https://github.com/calvindotsg/mac-upkeep/issues/53)) ([c83f865](https://github.com/calvindotsg/mac-upkeep/commit/c83f86587c435a07356639a06c26300a829d67cc))
9
+
10
+
11
+ ### CI/CD
12
+
13
+ * harden the release pipeline against findings F-05, F-06 and F-11 ([#54](https://github.com/calvindotsg/mac-upkeep/issues/54)) ([1d1fb5f](https://github.com/calvindotsg/mac-upkeep/commit/1d1fb5fd2e99e0e0d9bc6bd72da34e244fce44f4))
14
+
15
+ ## [3.0.3](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.2...v3.0.3) (2026-08-17)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * report handler tasks as dry-run instead of as having executed ([#51](https://github.com/calvindotsg/mac-upkeep/issues/51)) ([1db8054](https://github.com/calvindotsg/mac-upkeep/commit/1db8054d65da313f6fe4d51f00932d126fc59c79))
21
+
3
22
  ## [3.0.2](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.1...v3.0.2) (2026-08-16)
4
23
 
5
24
 
@@ -23,9 +23,10 @@ defaults.toml → bundled task definitions, loaded via importlib.resources
23
23
  config.py → TaskDef dataclass, load_task_defs(), resolve_variables(), get_brew_prefix(),
24
24
  Config.load() (3-layer merge: defaults.toml → user config → env vars)
25
25
  tasks.py → _build_cmd(), run_task(), _run(), run_all_tasks() data-driven loop,
26
- frequency scheduling, format_last_run(), format_next_run(), ANSI stripping
26
+ frequency scheduling, format_last_run(), format_next_run()
27
27
  cli.py → Typer app: run, tasks, init, show-config, setup, status (dashboard), logs, notify-test
28
- output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging (non-interactive)
28
+ output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging
29
+ (non-interactive), strip_control_sequences() (shared sanitiser)
29
30
  notify.py → macOS notifications via terminal-notifier (preferred) / osascript (fallback)
30
31
  ```
31
32
 
@@ -103,6 +104,95 @@ Reclaims Electron/editor caches mole's classifier structurally misses (`Service
103
104
  - **`_is_safe_target`** refuses anything not ≥2 segments below `~/Library/Application Support`, and refuses symlinks — mirrors mole's `validate_path_for_deletion`.
104
105
  - Ships **`enabled = false`** (opt-in: it's `rm -rf` and reaches all users, like `pnpm`). Targets default to Notion + Zed; override with `[[editor_cache.apps]]` (`name`/`process`/`min_size_mb`/`targets`) in user config.
105
106
 
107
+ ### Fail-closed contracts (security audit run-1)
108
+
109
+ Four gates were changed from fail-open to fail-closed. Each looks like an inconsequential
110
+ default until you notice which direction it fails in; do not "simplify" them back.
111
+
112
+ - **`Config.is_enabled()` returns `False` for an unknown key**, not `True`. Every caller
113
+ derives the key from a real `TaskDef` via `normalize_task_key()`, so a miss means the two
114
+ keyspaces diverged — which must never resolve to "run it". This is only safe *because*
115
+ keys are normalised at load: alone, it would stop a legitimately-enabled custom task with
116
+ a capitalised name from ever running. The two changes ship together or not at all.
117
+ - **`normalize_task_key()` is applied once in `load_task_defs()`** — to default keys, user
118
+ keys, and `run.order` entries — so `tasks.py`'s `task_key` derivation is an identity
119
+ operation. Before, `[tasks.DockerPrune]` was stored raw, `is_enabled` missed, and
120
+ `enabled = false` was ignored while the dashboard cheerfully printed "disabled". Two names
121
+ normalising to one key is a `ValueError`, because otherwise the winner depends on TOML
122
+ table order.
123
+ - **User field types are validated** (`_FIELD_TYPES` + `_check_field`) in *both* the override
124
+ branch and `_parse_task_def`. `enabled = "false"` — quoting a TOML boolean, the most common
125
+ TOML mistake — used to leave a truthy string and run the task anyway; `sudo = "false"`
126
+ *added* `sudo -n`. `bool` subclasses `int`, so `timeout = true` needs the explicit
127
+ `isinstance(value, bool)` rejection.
128
+ - **`MAC_UPKEEP_<TASK>` is an allowlist of truthy strings**, not a denylist of falsy ones.
129
+ `off`, `disabled` and `""` previously all enabled the task.
130
+
131
+ ### require_file must distinguish "unset" from "resolved to nothing"
132
+
133
+ `TaskDef.require_file_declared` records that the template was non-empty *before* variable
134
+ resolution. The old guard `if td.require_file and not Path(...).is_file()` short-circuited on
135
+ the empty string, so `brew_bundle` ran `--file=` — which Homebrew treats as absent, falling
136
+ back to `$PWD/Brewfile`, evaluating it as Ruby and then uninstalling everything it does not
137
+ list. It also failed on every run for every user with no Brewfile, burning retry backoff and
138
+ a notification each time. Related: `_discover_brewfile()` must never regain a CWD-relative
139
+ candidate, and `run_task` passes `cwd="/"` so no task can be steered by the caller's
140
+ directory (launchd already provides `/`).
141
+
142
+ ### git_sync: repository config is untrusted input
143
+
144
+ A repo's own `.git/config` can make git execute a command, and `safe.directory` does not
145
+ help — it keys on ownership, and an extracted archive is owned by the invoking user.
146
+ `_run_git` therefore prepends `_trusted_overrides()` to **every** call, not just `pull`:
147
+ `git status --porcelain`, the `skip_dirty` *safety* check, is itself enough to trigger
148
+ `core.fsmonitor` and filter drivers. All sinks below were verified to fire on git 2.55.
149
+
150
+ - `_STATIC_OVERRIDES` are unconditional: `core.fsmonitor=`, `core.hooksPath=/dev/null`,
151
+ `protocol.ext.allow=never` (a repo can set `protocol.ext.allow=always` and get `ext::`
152
+ back), `protocol.file.allow=never` (the only working block for `remote.<n>.uploadpack`,
153
+ which a per-name `-c` override does *not* beat), and `protocol.git.allow=never` (the only
154
+ working block for `core.gitProxy` — `-c core.gitProxy=` does **not** disable it).
155
+ - **Multi-valued and single-valued keys need opposite treatment, and confusing them is a
156
+ live outage.** `_INHERITED_LIST_KEYS` (`credential.helper`) is reset with an empty entry —
157
+ which for a list key means "discard what git accumulated" — then the user's global/system
158
+ values are appended back. A bare reset without the re-add would break nearly every macOS
159
+ user, because Homebrew git ships `credential.helper = osxkeychain` at *system* scope, and
160
+ the helper fires on an HTTP 401 despite `GIT_ASKPASS` and `GIT_TERMINAL_PROMPT`.
161
+ `_INHERITED_SCALAR_KEYS` (`core.sshCommand`) must instead be **set** to the user's value
162
+ or to an explicit default. An empty entry there is not a reset: git execs the empty string,
163
+ and every SSH remote fails with `error: cannot run :` on every run — a retry storm for the
164
+ default configuration, which is the one the README documents.
165
+ - **Known residual, do not claim otherwise:** `filter.<driver>.clean` from a planted
166
+ `.gitattributes` still executes on `status`. Driver names are arbitrary, so no fixed `-c`
167
+ set covers them, and git has no "ignore repo-local config" switch. Enrolment discipline is
168
+ the real control. Before adding a "this is the only remaining sink" claim anywhere, re-derive
169
+ the list against the installed git — `core.gitProxy` was missed on the first pass precisely
170
+ because the sink list was taken as complete rather than re-tested.
171
+ - `_trusted_cache` is process-global; `tests/conftest.py` seeds it with the static half so
172
+ argv is deterministic and the `git config` probes are not caught by tests that patch
173
+ `git_sync.subprocess.run`. Tests exercising the inherited half reset it to `None`.
174
+
175
+ ### Untrusted text is never Rich markup
176
+
177
+ `output.py` renders subprocess and remote-derived text as a literal `rich.text.Text`, never
178
+ interpolated into a markup string — in `task_debug` *and* in `summary`'s failure detail. A
179
+ bracketed path like `[/usr/local]` raises `MarkupError` (which used to abort the run and
180
+ leave the cursor hidden, since `Live.__exit__` never ran), and `[link=…]` renders a real
181
+ OSC-8 hyperlink with attacker-chosen target and anchor text inside our own failure summary.
182
+ `strip_control_sequences()` in `output.py` is the single sanitiser for both `tasks.py` and
183
+ `git_sync.py`; it covers all escape sequences plus stray C0/C1 characters, not just SGR
184
+ colour codes. It deliberately does **not** touch `[` — markup is handled by `Text`, not by
185
+ escaping.
186
+
187
+ ### No single task may abort the run
188
+
189
+ `run_task` catches `OSError` (a shim whose shebang interpreter vanished is the routine
190
+ trigger), `run_all_tasks` wraps each task in a catch-all, and `cli.run` passes its own
191
+ `results` list in so `output.summary()` and `notify()` still describe whatever completed.
192
+ Under launchd the notification is the user's only feedback channel, and `summary()` is what
193
+ exits the Rich `Live` context. The dead `CalledProcessError` branch was removed — `check=True`
194
+ is never passed, so it read as coverage that did not exist.
195
+
106
196
  ### sudo + HOME/USER/LOGNAME
107
197
 
108
198
  `sudo -n` with full path `$BREW_PREFIX/bin/mo`. Sudoers `env_keep += "HOME USER LOGNAME"`. The `sudo` field in `TaskDef` exists instead of embedding `sudo` in the command so that: (a) dry-run can skip sudo, (b) `detect` infers the correct binary, (c) `mac-upkeep setup` can generate sudoers rules.
@@ -139,7 +229,7 @@ env USER=root MOLE_DRY_RUN=1 mo optimize | grep -A2 'Permission Repair' # enters
139
229
  - **Config/state paths are import-time constants**, so setting `XDG_CONFIG_HOME` from a fixture is too late — patch `mac_upkeep.config.DEFAULT_CONFIG_PATH`, `mac_upkeep.cli.DEFAULT_CONFIG_PATH`, `tasks._STATE_FILE` and `tasks._RETRY_FILE` objects directly. `Config.load(path=None)` resolves `DEFAULT_CONFIG_PATH` **at call time**; as a default argument it would bind at def time and silently ignore every patch.
140
230
  - **`git_sync` SSH auth under launchd** relies on `~/.ssh/config` `IdentityAgent` (path-based, e.g. 1Password socket). `SSH_AUTH_SOCK` env vars are NOT inherited by LaunchAgents, so env-based agent forwarding won't work here — the `IdentityAgent` directive is the supported path.
141
231
  - **`git_sync` forces `GIT_TERMINAL_PROMPT=0` and defaults `GIT_ASKPASS=/usr/bin/true`** (user-set `GIT_ASKPASS` is respected) — fail-fast on auth misconfiguration instead of stalling to the 60 s subprocess timeout. A genuine stall (network/server hang) still hits the timeout, so `_run_git` catches `subprocess.TimeoutExpired` and returns a synthetic `CompletedProcess(returncode=124, stderr="timed out after {timeout}s")`. That marks only the affected repo failed and lets the loop continue — a hung pull cannot abort the whole run (which would otherwise skip `output.summary()`/`notify()` and later tasks).
142
- - **`git_sync` failure reasons belong in the aggregate `TaskResult`, not only `output.task_debug()`** — debug output is suppressed without `--debug`, so the log recorded `13 failed: <names>` with no cause, which is not diagnosable after the fact. `_format_failures()` groups repos by reason because the common case is one shared cause (a network drop failing every repo at once); it caps names per group and truncates long messages so a 16-repo outage cannot blow up the log line or the notification body.
232
+ - **`git_sync` failure reasons belong in the aggregate `TaskResult`, not only `output.task_debug()`** — debug output is suppressed without `--debug` on the non-interactive path (`Output.debug` is set but not consulted by the interactive branch, so a TTY always sees it), so the log recorded `13 failed: <names>` with no cause, which is not diagnosable after the fact. `_format_failures()` groups repos by reason because the common case is one shared cause (a network drop failing every repo at once); it caps names per group and truncates long messages so a 16-repo outage cannot blow up the log line or the notification body.
143
233
 
144
234
  ## Release Process
145
235
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mac-upkeep
3
- Version: 3.0.2
3
+ Version: 3.0.4
4
4
  Summary: Automated macOS maintenance CLI
5
5
  Project-URL: Homepage, https://github.com/calvindotsg/mac-upkeep
6
6
  Project-URL: Repository, https://github.com/calvindotsg/mac-upkeep
@@ -127,6 +127,23 @@ frequency = "monthly"
127
127
  brewfile = "~/.config/Brewfile"
128
128
  ```
129
129
 
130
+ Task fields are type-checked. TOML booleans are unquoted — `enabled = false`, not
131
+ `enabled = "false"` — and a quoted one is now rejected with an error rather than
132
+ silently leaving the task enabled. Task names are matched case- and space-insensitively
133
+ (`[tasks.Docker Prune]` and `[tasks.docker_prune]` are the same task, and declaring both
134
+ is an error).
135
+
136
+ #### Brewfile discovery
137
+
138
+ With no `[paths] brewfile` and no `MAC_UPKEEP_BREWFILE`/`HOMEBREW_BUNDLE_FILE`, only two
139
+ absolute locations are searched: `$XDG_CONFIG_HOME/Brewfile` (default `~/.config/Brewfile`)
140
+ and `~/.Brewfile`. **The current working directory is deliberately not searched.**
141
+ `brew bundle` evaluates a Brewfile as Ruby and `brew_bundle` runs `cleanup --force`, so a
142
+ CWD-relative lookup made whichever project directory your shell happened to be in able to
143
+ run code and uninstall every package it did not list. If no Brewfile is found the task
144
+ skips; it no longer runs with an empty `--file=`, which Homebrew resolved back to
145
+ `$PWD/Brewfile`.
146
+
130
147
  ### Custom tasks
131
148
 
132
149
  Add your own tasks using the same format:
@@ -158,6 +175,32 @@ skip_dirty = true # skip repos with uncommitted changes
158
175
 
159
176
  Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
160
177
 
178
+ #### Only enrol repositories you created
179
+
180
+ **A git repository's own `.git/config` can make git execute commands.** Point a `repos` glob only at directories you created yourself — never at a downloads, sync, backup, or vendor-drop directory. A tree that arrives by archive, restore, or file sync brings its `.git/config` and `.git/hooks` with it, and `safe.directory` does not help: it keys on ownership, and anything you unpacked is owned by you. A plain `git clone` does *not* carry these, so cloning is unaffected.
181
+
182
+ mac-upkeep neutralises the directives it can, on every git call — not just on `pull`, because `git status --porcelain` (the `skip_dirty` check itself) is enough to trigger some of them:
183
+
184
+ | Neutralised | How |
185
+ |---|---|
186
+ | `core.fsmonitor` | reset to empty |
187
+ | `.git/hooks/*` | `core.hooksPath=/dev/null` |
188
+ | `credential.helper` | list reset, then your own global/system helpers re-added |
189
+ | `core.sshCommand` | set to your own global/system value, else explicitly `ssh` |
190
+ | `ext::` transport | `protocol.ext.allow=never` |
191
+ | `remote.<name>.uploadpack` | `protocol.file.allow=never` |
192
+ | `core.gitProxy` | `protocol.git.allow=never` |
193
+
194
+ Note the asymmetry in the middle two rows. `credential.helper` is multi-valued, so an empty entry resets git's accumulated list. `core.sshCommand` is single-valued, so an empty entry is not a reset — git would try to execute the empty string and every SSH remote would fail. Single-valued keys are therefore *set*, never blanked.
195
+
196
+ Three consequences worth knowing:
197
+
198
+ - **Local-path remotes no longer work** under git_sync (`fatal: transport 'file' not allowed`). This is deliberate — it is what closes the `uploadpack` execution path. Pull from a bare mirror on an external disk outside mac-upkeep.
199
+ - **`git://` remotes no longer work** (`fatal: transport 'git' not allowed`). Also deliberate: `core.gitProxy` runs an arbitrary command for that transport and cannot be neutralised any other way. `git://` is unauthenticated and unencrypted; use SSH or HTTPS.
200
+ - **Repository hooks do not run** during git_sync, so a `post-merge` hook that installs dependencies will not fire on an unattended pull.
201
+
202
+ This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch. The known remaining execution path is `filter.<driver>.clean` from a planted `.gitattributes`, which fires on `git status`: driver names are arbitrary, so no fixed override covers them. Enrolment discipline is the control that actually holds.
203
+
161
204
  #### Authentication
162
205
 
163
206
  Any of the following work under launchd without mac-upkeep-side configuration:
@@ -175,6 +218,11 @@ MAC_UPKEEP_GCLOUD=false mac-upkeep run # Disable a task
175
218
  MAC_UPKEEP_GCLOUD_FREQUENCY=monthly mac-upkeep run # Override frequency
176
219
  ```
177
220
 
221
+ `MAC_UPKEEP_<TASK>` enables the task only for an explicitly truthy value —
222
+ `true`, `1`, `yes`, or `on`. **Anything else disables it**, including `off`, `disabled`
223
+ and the empty string, which previously all *enabled* the task by falling through a
224
+ denylist of `false`/`0`/`no`.
225
+
178
226
  ### Sudoers
179
227
 
180
228
  `mo_clean` and `mo_optimize` require passwordless sudo for the `mo` binary:
@@ -195,6 +243,22 @@ Validate before installing — a malformed file in `/etc/sudoers.d/` can lock yo
195
243
  >
196
244
  > 3.0.0 generates `env_keep += "HOME USER LOGNAME"`. Verify with `sudo -n $(brew --prefix)/bin/mo optimize </dev/null >/dev/null; echo $?` — it should print `0`.
197
245
 
246
+ ### Log file permissions
247
+
248
+ `mac-upkeep setup` now prints the `newsyslog.d` line with mode **640** instead of 644. The
249
+ log records `git_sync` failures by repository name, which enumerates your private and
250
+ employer-internal repositories, and `$(brew --prefix)/var/log` is world-traversable — unlike
251
+ `~/Library`, nothing else gates it. Owner plus the `admin` group keeps `mac-upkeep logs`
252
+ working.
253
+
254
+ > **⚠ Already installed?** Like the sudoers file, `/etc/newsyslog.d/mac-upkeep.conf` is
255
+ > **not** upgraded by `brew upgrade`, and existing log files keep their 644 mode. Rewrite
256
+ > the conf from `mac-upkeep setup`, then:
257
+ >
258
+ > ```bash
259
+ > sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*
260
+ > ```
261
+
198
262
  ## Contributing
199
263
 
200
264
  See [CONTRIBUTING.md](https://github.com/calvindotsg/mac-upkeep/blob/main/CONTRIBUTING.md) for development setup and conventions.
@@ -100,6 +100,23 @@ frequency = "monthly"
100
100
  brewfile = "~/.config/Brewfile"
101
101
  ```
102
102
 
103
+ Task fields are type-checked. TOML booleans are unquoted — `enabled = false`, not
104
+ `enabled = "false"` — and a quoted one is now rejected with an error rather than
105
+ silently leaving the task enabled. Task names are matched case- and space-insensitively
106
+ (`[tasks.Docker Prune]` and `[tasks.docker_prune]` are the same task, and declaring both
107
+ is an error).
108
+
109
+ #### Brewfile discovery
110
+
111
+ With no `[paths] brewfile` and no `MAC_UPKEEP_BREWFILE`/`HOMEBREW_BUNDLE_FILE`, only two
112
+ absolute locations are searched: `$XDG_CONFIG_HOME/Brewfile` (default `~/.config/Brewfile`)
113
+ and `~/.Brewfile`. **The current working directory is deliberately not searched.**
114
+ `brew bundle` evaluates a Brewfile as Ruby and `brew_bundle` runs `cleanup --force`, so a
115
+ CWD-relative lookup made whichever project directory your shell happened to be in able to
116
+ run code and uninstall every package it did not list. If no Brewfile is found the task
117
+ skips; it no longer runs with an empty `--file=`, which Homebrew resolved back to
118
+ `$PWD/Brewfile`.
119
+
103
120
  ### Custom tasks
104
121
 
105
122
  Add your own tasks using the same format:
@@ -131,6 +148,32 @@ skip_dirty = true # skip repos with uncommitted changes
131
148
 
132
149
  Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
133
150
 
151
+ #### Only enrol repositories you created
152
+
153
+ **A git repository's own `.git/config` can make git execute commands.** Point a `repos` glob only at directories you created yourself — never at a downloads, sync, backup, or vendor-drop directory. A tree that arrives by archive, restore, or file sync brings its `.git/config` and `.git/hooks` with it, and `safe.directory` does not help: it keys on ownership, and anything you unpacked is owned by you. A plain `git clone` does *not* carry these, so cloning is unaffected.
154
+
155
+ mac-upkeep neutralises the directives it can, on every git call — not just on `pull`, because `git status --porcelain` (the `skip_dirty` check itself) is enough to trigger some of them:
156
+
157
+ | Neutralised | How |
158
+ |---|---|
159
+ | `core.fsmonitor` | reset to empty |
160
+ | `.git/hooks/*` | `core.hooksPath=/dev/null` |
161
+ | `credential.helper` | list reset, then your own global/system helpers re-added |
162
+ | `core.sshCommand` | set to your own global/system value, else explicitly `ssh` |
163
+ | `ext::` transport | `protocol.ext.allow=never` |
164
+ | `remote.<name>.uploadpack` | `protocol.file.allow=never` |
165
+ | `core.gitProxy` | `protocol.git.allow=never` |
166
+
167
+ Note the asymmetry in the middle two rows. `credential.helper` is multi-valued, so an empty entry resets git's accumulated list. `core.sshCommand` is single-valued, so an empty entry is not a reset — git would try to execute the empty string and every SSH remote would fail. Single-valued keys are therefore *set*, never blanked.
168
+
169
+ Three consequences worth knowing:
170
+
171
+ - **Local-path remotes no longer work** under git_sync (`fatal: transport 'file' not allowed`). This is deliberate — it is what closes the `uploadpack` execution path. Pull from a bare mirror on an external disk outside mac-upkeep.
172
+ - **`git://` remotes no longer work** (`fatal: transport 'git' not allowed`). Also deliberate: `core.gitProxy` runs an arbitrary command for that transport and cannot be neutralised any other way. `git://` is unauthenticated and unencrypted; use SSH or HTTPS.
173
+ - **Repository hooks do not run** during git_sync, so a `post-merge` hook that installs dependencies will not fire on an unattended pull.
174
+
175
+ This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch. The known remaining execution path is `filter.<driver>.clean` from a planted `.gitattributes`, which fires on `git status`: driver names are arbitrary, so no fixed override covers them. Enrolment discipline is the control that actually holds.
176
+
134
177
  #### Authentication
135
178
 
136
179
  Any of the following work under launchd without mac-upkeep-side configuration:
@@ -148,6 +191,11 @@ MAC_UPKEEP_GCLOUD=false mac-upkeep run # Disable a task
148
191
  MAC_UPKEEP_GCLOUD_FREQUENCY=monthly mac-upkeep run # Override frequency
149
192
  ```
150
193
 
194
+ `MAC_UPKEEP_<TASK>` enables the task only for an explicitly truthy value —
195
+ `true`, `1`, `yes`, or `on`. **Anything else disables it**, including `off`, `disabled`
196
+ and the empty string, which previously all *enabled* the task by falling through a
197
+ denylist of `false`/`0`/`no`.
198
+
151
199
  ### Sudoers
152
200
 
153
201
  `mo_clean` and `mo_optimize` require passwordless sudo for the `mo` binary:
@@ -168,6 +216,22 @@ Validate before installing — a malformed file in `/etc/sudoers.d/` can lock yo
168
216
  >
169
217
  > 3.0.0 generates `env_keep += "HOME USER LOGNAME"`. Verify with `sudo -n $(brew --prefix)/bin/mo optimize </dev/null >/dev/null; echo $?` — it should print `0`.
170
218
 
219
+ ### Log file permissions
220
+
221
+ `mac-upkeep setup` now prints the `newsyslog.d` line with mode **640** instead of 644. The
222
+ log records `git_sync` failures by repository name, which enumerates your private and
223
+ employer-internal repositories, and `$(brew --prefix)/var/log` is world-traversable — unlike
224
+ `~/Library`, nothing else gates it. Owner plus the `admin` group keeps `mac-upkeep logs`
225
+ working.
226
+
227
+ > **⚠ Already installed?** Like the sudoers file, `/etc/newsyslog.d/mac-upkeep.conf` is
228
+ > **not** upgraded by `brew upgrade`, and existing log files keep their 644 mode. Rewrite
229
+ > the conf from `mac-upkeep setup`, then:
230
+ >
231
+ > ```bash
232
+ > sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*
233
+ > ```
234
+
171
235
  ## Contributing
172
236
 
173
237
  See [CONTRIBUTING.md](https://github.com/calvindotsg/mac-upkeep/blob/main/CONTRIBUTING.md) for development setup and conventions.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mac-upkeep"
3
- version = "3.0.2"
3
+ version = "3.0.4"
4
4
  description = "Automated macOS maintenance CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"