Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2778a31
Implement #4: local secret store with redaction guarantees
Sep 30, 2026
e2eae59
Implement #5: task model and SQLite task store
Sep 30, 2026
665e47f
Implement #6: GitHub snapshot port with commit-pinned fetching
Sep 30, 2026
9a4b1b2
Implement #7: pure watch state machine
Sep 30, 2026
315a351
Implement #8: native runner with cadence, backoff, locking, recovery
Sep 30, 2026
4b847b0
Implement #9: notification sink and deduplicated inbox
Sep 30, 2026
c623cd6
Implement #10: CLI task inbox, watch lifecycle, activity views
Sep 30, 2026
31f07ca
Implement #11: inference provider port, API adapter, egress boundary
Sep 30, 2026
4921ff6
Implement #12: memory subsystem — gated writes, provenance, tombstone…
Sep 30, 2026
f6f1423
Implement #13: permissions — modes, scoped grants, approvals surface
Sep 30, 2026
32f11f4
Implement #14: end-to-end PR-watch validation suite, all fakes
Sep 30, 2026
041946d
[skip ci] Fix secret storage and config review findings (#17)
hsliuustc0106 Sep 30, 2026
b4783c3
[skip ci] Fix task scope and lifecycle review findings (#18)
hsliuustc0106 Sep 30, 2026
9621723
[skip ci] Fix required-check snapshots and GitHub review findings (#19)
hsliuustc0106 Sep 30, 2026
e5b4535
[skip ci] Fix commit-aware transitions and recurrence identity (#20)
hsliuustc0106 Sep 30, 2026
8ff3b96
[skip ci] Fix runner recovery and task-failure isolation (#21)
hsliuustc0106 Sep 30, 2026
3679daf
[skip ci] Fix notification transport and recurring-event delivery (#22)
hsliuustc0106 Sep 30, 2026
a18003a
[skip ci] Fix CLI scope validation and safe runner lifecycle (#23)
hsliuustc0106 Sep 30, 2026
091073d
[skip ci] Fix production inference safety and bounded summaries (#24)
hsliuustc0106 Sep 30, 2026
64fe01b
[skip ci] Fix memory CLI safety, expiry and completion ordering (#25)
hsliuustc0106 Sep 30, 2026
6610d84
[skip ci] Fix permission gates, expiry and structural grant revocatio…
hsliuustc0106 Sep 30, 2026
ff58673
[skip ci] Fix offline CI and validate the reviewed MVP stack (#27)
hsliuustc0106 Sep 30, 2026
0492040
Merge pull request #27 from ThinkFlowLab/issue-14
hsliuustc0106 Sep 30, 2026
886c62f
Integrate reviewed MVP stack while preserving current main
hsliuustc0106 Oct 1, 2026
4989a7d
Add credential-free first-use PR watch, crash-recovery demo and safe …
hsliuustc0106 Oct 1, 2026
c1e03de
Fix case-insensitive GitHub Actions environment validation
hsliuustc0106 Oct 1, 2026
a8c704e
Fix PR watch failure alerts, replay identity, and cooperative shutdown
hsliuustc0106 Oct 1, 2026
661f4ba
Alert on optional failures while required checks are pending
hsliuustc0106 Oct 1, 2026
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
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,18 @@ jobs:
python-version: "3.12"
- name: Install
run: pip install -e ".[dev]"
- name: Test
run: pytest -q
- name: Test (offline, all fakes)
env:
# Restrict offline enforcement to tests; checkout and installation
# need network access. The autouse socket guard in conftest.py also
# rejects direct network calls that do not honor proxy variables.
HTTP_PROXY: "http://127.0.0.1:9"
HTTPS_PROXY: "http://127.0.0.1:9"
ALL_PROXY: "http://127.0.0.1:9"
NO_PROXY: ""
run: |
# Actions env keys are case-insensitive; define lowercase aliases
# in the shell so both spellings reach pytest and its subprocesses.
export http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY"
export all_proxy="$ALL_PROXY" no_proxy="$NO_PROXY"
python -m pytest -q
188 changes: 187 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,188 @@
# nanodot
nanodot is a minimal, open-source AI assistant, designed for persistent memory and proactive task execution.

A minimal, local-first Python assistant with persistent memory and a read-only
GitHub PR watcher. The native CLI and runner work without an inference provider.
Linux and macOS are supported; OS notifications use macOS `osascript`, with a
persistent inbox available on either platform.

## Install and run

Python 3.11 or newer is required. From this checkout:

```sh
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e '.[dev]'
nanodot --help
```

## First use: watch a public PR

Try the complete offline lifecycle first (no token, model, or network):

```sh
python examples/first_pr_watch.py
```

It runs eight scenarios through the real CLI in separate processes, including
an actual crash/restart, inbox deduplication, stale-commit rejection, and stop.
The PR transitions are simulated; the CLI, native parsing, runner and database
are real. The output includes a report and command transcript.

For a real public PR, use an isolated data home and explicit anonymous access:

```sh
export NANODOT_HOME="$(mktemp -d)"
nanodot config set github-auth-mode anonymous
nanodot config set os-notifications false
nanodot watch add owner/repo#123 --cadence 1800 --yes
nanodot runner --once
nanodot watch list
nanodot inbox
```

Anonymous mode never sends a GitHub token, even if one is saved. Stop the runner
before changing authentication mode or OS-notification settings, then restart
it; live changes to those settings are rejected. Public metadata
can be restricted and has a smaller API quota; 30-minute polling is a cautious
starting point. Unknown required-check rules cannot complete a watch, but
observed current-head failures still notify. No token or model is needed. See the [first-use walkthrough](docs/first-pr-watch.md) for the full
lifecycle, background runner, cancellation, and verification details.

For private repositories or authenticated reads, keep the default token mode
(or set `github-auth-mode token`) and configure an existing read-only token with
a hidden terminal prompt:

```sh
nanodot config set github-token
```

For noninteractive entry, `nanodot config set github-token -` reads one line
from stdin. Avoid literal secrets in command arguments: they can appear in shell
history and process listings. nanodot does not create tokens or expand grants.

The GitHub adapter reads PRs, check suites/runs, commit statuses, applicable
branch/ruleset metadata, and GitHub Actions workflow metadata. The token must be
able to read those resources for the repository. Some classic protection
metadata requires Administration **read** permission. Missing access fails
closed; never grant write permission just to use a watcher.

```sh
nanodot watch add owner/repo#123
nanodot watch list
nanodot watch show TASK_ID
nanodot runner --once
nanodot start
nanodot status
nanodot inbox
nanodot activity TASK_ID
nanodot watch pause TASK_ID
nanodot watch resume TASK_ID
nanodot watch cancel TASK_ID
nanodot stop
```

`watch add` previews the saved scope and asks for confirmation (`--yes` skips
that prompt). The default cadence is 300 seconds; `--cadence` changes it. Fix a
lost token before resuming a blocked watch. Resume schedules it immediately.
Cancelled and completed watches cannot be restarted; create a new one instead.
Only one runner may hold a data home's lifetime lock. Shutdown is cooperative;
a timeout reports that stopping is still pending rather than signaling a saved
PID or claiming the process exited.

### Fixed watch policy

This MVP supports a fixed, validated policy. It notifies on new commits, check
failures, access blockers, and terminal outcomes. It stops when the required
checks pass on the current head, or the PR merges or closes. `--notify` and
`--stop` accept the supported policy text (including the original shipped
defaults) only; arbitrary natural-language conditions are rejected. The
`--purpose` text is descriptive and does not change execution. Unsupported
legacy rows remain inspectable/cancellable and are blocked before fetching.

A passing result requires a complete paginated snapshot and known required
check rules for the PR's base branch. Missing required contexts, stale commits,
unknown rules, or inaccessible metadata cannot satisfy a watch. Legacy commit
statuses are included; latest reruns are evaluated without mixing app sources.
Observed current-head failures, including optional checks, notify while required
success is unconfirmed. Optional failures do not block a confirmed required-check pass.

Conservative limits:

- No configured required checks keeps a watch active until the PR closes or
merges; optional green checks are not treated as proof of a requirement.
Observed current-head failures still notify when rules are empty or hidden
- Neutral/skipped results stay pending; nanodot requires literal success
- App-bound legacy statuses cannot be proven from REST creator identity, so
they stay pending when app provenance cannot be verified
- Unsupported rule types (for example merge queues or required workflows) and
unresolved relevant suites stay pending
- This watches current-head CI; it does not certify full mergeability or
evaluate the separate merge-queue/merge-test commit

## Optional inference

```sh
nanodot config set api-key
nanodot config set model-base-url https://api.openai.com/v1
nanodot config set model-name YOUR_MODEL
nanodot watch add --intent 'watch owner/repo#123 until required checks pass'
```

With a configured provider, the explicit intent text and whitelisted PR/check
metadata can be sent to that provider. Memory contents and local history are
not included. Known stored secret values are scrubbed from all outbound fields;
credentials travel only in authorization headers. Review
[the egress contract](docs/design/egress.md) before enabling it. Optional
summaries have a one-second scheduler wait budget and at most one in-flight
request; late/error responses are discarded and raw notifications still work.

## Memory and permissions

```sh
nanodot memory add 'prefer morning deploys'
nanodot memory propose 'review after lunch'
nanodot memory list
nanodot memory show ITEM_ID
nanodot memory confirm ITEM_ID
nanodot memory edit ITEM_ID --content 'prefer afternoon deploys'
nanodot memory rm ITEM_ID
nanodot approvals
```

User statements are confirmed; proposals need explicit confirmation and expire
after 14 days. Expiry is enforced when opening/reading/confirming memory.
Terminal observations are best-effort after durable task completion. Deletion
removes the item, securely overwrites deleted SQLite cells, and leaves a
contentless activity tombstone. This is not a promise to erase filesystem
snapshots, backups, or copies retained outside nanodot.

Only `readonly` mode is implemented. `gated`/`auto` cannot be enabled, and all
write actions are denied. Grants and approval records remain inspectable;
scope changes revoke related grants and pending requests atomically.

Data defaults to `~/.nanodot`; `NANODOT_HOME` selects an isolated directory.
Secrets are stored in a private `0600` file via atomic replacement with symlink
checks. SQLite holds task state, memory, activity, and inbox entries. Existing
plaintext secret-named config values are masked on listing and removed when
reset/unset. A process killed during an atomic save may leave a private temporary
file; system backups and shell history remain outside these guarantees.

## Test offline

Install dependencies first, then run:

```sh
HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 \
ALL_PROXY=http://127.0.0.1:9 NO_PROXY= python -m pytest -q
```

The test fixture rejects in-process IP sockets and DNS resolution, while native
transports are replaced by fakes. Dead proxy settings are inherited by child
processes. This is a test tripwire, not an OS firewall sandbox for arbitrary
subprocesses. CI applies offline proxy settings to the test step only, after
checkout, Python setup, and dependency installation.

See [adapter contracts](docs/design/adapter-seam.md) for dependency direction and
[the safety/validation review](docs/design/safety-validation.md) for the review
coverage and integration plan.
7 changes: 4 additions & 3 deletions docs/design/adapter-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ provide fakes; a future adapter would be another implementation.
### 2. GitHub snapshot fetch

- **Purpose:** return a commit-pinned view of a PR: open/merged/closed
state, current head SHA, and check runs keyed to that SHA.
state, current head SHA, and complete check/status results keyed to that SHA, with known required-check
metadata from the base branch. Unknown metadata cannot prove success.
- **Interface sketch:** `fetch(task) -> Snapshot | FetchError`
(`FetchError` typed as retryable / auth-lost / not-found).
- **Native implementation:** read-only REST client with a read-only PAT (#6).
Expand All @@ -55,8 +56,8 @@ provide fakes; a future adapter would be another implementation.
- **Interface sketch:** `notify(event) -> None`, idempotent per event key.
- **Native implementation:** deduplicated persisted inbox + macOS
notification (#9). Slack/email would be further implementations.
- **An adapter would implement:** delivery through another channel; dedup
policy stays in core.
- **An adapter would implement:** delivery through another channel; transition identity stays in core; sinks retain delivery
deduplication by durable occurrence identity across retries and restarts.

### 4. Inference provider

Expand Down
43 changes: 43 additions & 0 deletions docs/design/egress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Egress — exactly what leaves the host

Nanodot is local-first: task state, memory, the activity log, and the inbox
live in `~/.nanodot` and never leave the machine. Two things leave, and
only through their ports:

## 1. GitHub reads (the snapshot port)

- **Destination:** `api.github.com` (read-only REST).
- **Content:** repository/PR identifiers, and the responses (check names,
statuses, conclusions, head SHAs), applicable branch/ruleset requirements,
and workflow event metadata. Request paths only — no POST/PUT/
PATCH/DELETE is ever issued by the GitHub client.
- **Credential:** the read-only PAT travels in the `Authorization` header
and exists nowhere else outside the secret store.

## 2. Inference (the provider port) — the only other egress point

Requests are built by `core.egress.EgressGuard` from a fixed whitelist;
there is structurally no way to attach anything else:

- **summarize:** `kind`, `summary` (the raw state-change message), `head_sha`,
`pr_state`, `url`, `checks` (name + conclusion per check). I.e., public
PR metadata and check outcomes.
- **parse_intent:** `intent_text` — the sentence the user just typed.
- **Never:** credentials, tokens, task-store contents, memory items,
activity history, inbox contents, file paths.
- Known secret values are additionally scrubbed recursively from every outbound
value, including check names and nested evidence. The production factory
supplies the configured secret store to the redactor.

If the model is an API model, the above data leaves the host to that
provider; the API key travels only in the `Authorization` header. A local
model behind the same interface removes this egress entirely — no other
code changes.

Everything else — scheduling, state transitions, commit-pinning, dedup,
memory writes, redaction — happens locally.

Optional provider summaries wait at most one second in the scheduler. At most
one summary call is in flight; late responses are discarded. Native HTTP calls
use a five-second timeout. Python cannot forcibly cancel an arbitrary provider,
so a still-running call suppresses further summaries while raw events continue.
79 changes: 79 additions & 0 deletions docs/design/safety-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Safety and validation review

These changes build on the complete MVP stack at `32f11f4` (`issue-14`, PR #27).
Main at review time was `82eb79a`, containing only the scaffold and adapter-seam
documentation. Existing PRs remain open; corrections are added without rewriting history.

## Reviewed scope

All eleven open PRs (#17–27) were checked on 2026-09-30. Each had one submitted
review by `hsliuustc0106` at 22:43–22:44 UTC; none had inline review threads or
conversation comments. The patch addresses the functional feedback on the
combined stack:

| PR | Correction |
| --- | --- |
| [17](https://github.com/ThinkFlowLab/nanodot/pull/17#pullrequestreview-5372793871) | Atomic/symlink-safe secret writes, hidden/stdin input, common secret-name routing |
| [18](https://github.com/ThinkFlowLab/nanodot/pull/18#pullrequestreview-5372794516) | Resume scheduling, terminal guards, duplicate-ID domain error |
| [19](https://github.com/ThinkFlowLab/nanodot/pull/19#pullrequestreview-5372795262) | Complete paginated checks/statuses, required metadata, secondary throttling, early failure outcome |
| [20](https://github.com/ThinkFlowLab/nanodot/pull/20#pullrequestreview-5372795847) | New-commit failure reset and durable occurrence identity |
| [21](https://github.com/ThinkFlowLab/nanodot/pull/21#pullrequestreview-5372796525) | Recovery without scheduler test workarounds, per-task unexpected-failure isolation |
| [22](https://github.com/ThinkFlowLab/nanodot/pull/22#pullrequestreview-5372797580) | Notification text passed as AppleScript arguments; recurring failures delivered once per occurrence |
| [23](https://github.com/ThinkFlowLab/nanodot/pull/23#pullrequestreview-5372798249) | Safe runner ownership/start/stop controls, lightweight read-only CLI wiring |
| [24](https://github.com/ThinkFlowLab/nanodot/pull/24#pullrequestreview-5372799026) | Factory redaction, nested egress scrubbing, bounded optional summaries, fenced JSON parsing |
| [25](https://github.com/ThinkFlowLab/nanodot/pull/25#pullrequestreview-5372799810) | Real memory CLI safety/tombstones, enforced proposal expiry, secure deletion, observation failure isolation |
| [26](https://github.com/ThinkFlowLab/nanodot/pull/26#pullrequestreview-5372800905) | Reject unsupported modes, unconditional external-write denial, structural scope revocation, expiry-aware approval/read |
| [27](https://github.com/ThinkFlowLab/nanodot/pull/27#pullrequestreview-5372801573) | Unmasked lifecycle regressions, real CLI entrypoint tests, stronger offline tripwire, test-step-only CI proxy |

#24's missing production provider wiring was already addressed in #25; this
patch preserves it and fixes its redactor. Unsupported custom `--stop`/`--notify`
text is now rejected instead of being saved and ignored.

Small nonfunctional suggestions are intentionally not a new architecture
project: stores still use SQLite's normal busy timeout and do not enable WAL;
permanent secret caching is avoided so rotation stays visible; the small
no-secret fallback objects are unchanged. Secure deletion is explicitly enabled
rather than relying on the host SQLite build default.

## Verification boundaries

Tests use real core/SQLite and fake transports, including production CLI and
provider factories. No real provider calls, credentials, authorization grants,
notifications to a user, CI workflow runs, or repository write operations are
needed. Native macOS rendering is not exercised on Linux; the exact
`osascript` argv boundary is tested. This is not a paid or live end-to-end test.

Required-check completion deliberately fails closed for unavailable/unsupported
metadata. It is stricter than GitHub's merge gate and is not a mergeability
promise. See README limitations. Tests establish the behavior with mocks;
real repository permission differences remain an operational consideration.

## Integration recommendation

Review the existing dependency stack in order (#17 → #27). Each corrected
branch preserves its original head as a parent and incorporates the corrected
predecessor as an additional parent, keeping updates fast-forward and each PR
focused on its own feature. No PR is merged and no branch history is rewritten.
Do not retarget later features directly onto scaffold-only main without their
dependencies.

Publication commits carry `[skip ci]`, which suppresses the repository's only
workflow triggers, push and pull_request. The repository is public and uses
standard `ubuntu-latest`, whose Actions compute is free. This work does not
disable workflows, purchase credits, rerun checks, merge PRs, or enable
auto-merge. Remote CI is intentionally not evidence for these commits.

## Local verification result

On Python 3.12, the original correction package passed **386 tests**; the
published stack adds two production regressions and passes **388 tests** with dead proxies plus
in-process socket/DNS rejection. Compile checks, dependency checks, CLI help,
and `git diff --check` pass. Both the fixes-only patch on `32f11f4` and the
full-stack patch on `82eb79a` apply cleanly and produce the same source tree;
the reconstructed main-based correction-package tree also passes its full suite.
Each intermediate feature branch was independently tested before publication.

For comparison, the unmodified `32f11f4` baseline passed 107 of 108 tests on this
host: its deletion byte-check failed because secure deletion depended on the
SQLite build default. The final code sets it explicitly. This local result is
independent of the reviews' historical test claims. Remote CI was not run.
Loading
Loading