Skip to content

Commit 2476fe3

Browse files
committed
overall security gaps hardening
1 parent 9108e7b commit 2476fe3

36 files changed

Lines changed: 2660 additions & 229 deletions

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ on:
55
push:
66
branches:
77
- main
8+
# Also run on version tags so the exact released ref is checked.
9+
tags:
10+
- "v*"
811

912
permissions:
1013
contents: read
@@ -67,6 +70,31 @@ jobs:
6770
- name: Build distributions
6871
run: uv build --out-dir build-dist
6972

73+
integration:
74+
name: Integration tests (network)
75+
runs-on: ubuntu-latest
76+
timeout-minutes: 15
77+
# Integration-marked tests hit the live network and can flake for reasons
78+
# unrelated to the code; keep the signal visible without blocking PRs.
79+
continue-on-error: true
80+
steps:
81+
- name: Check out source
82+
uses: actions/checkout@v7
83+
with:
84+
persist-credentials: false
85+
86+
- name: Install uv and Python
87+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
88+
with:
89+
enable-cache: true
90+
python-version: "3.12"
91+
92+
- name: Install locked dependencies
93+
run: uv sync --locked --all-extras --dev
94+
95+
- name: Run integration tests
96+
run: uv run pytest -m integration -q
97+
7098
platform:
7199
name: ${{ matrix.name }} smoke test
72100
runs-on: ${{ matrix.runner }}
@@ -109,8 +137,18 @@ jobs:
109137
rg --version
110138
111139
- name: Run tests
140+
if: runner.os != 'macOS'
112141
run: uv run pytest tests -W error::pytest.PytestUnraisableExceptionWarning
113142

143+
- name: Run tests with coverage (macOS)
144+
if: runner.os == 'macOS'
145+
# Collect coverage on macOS too so mac-only paths are measured, but
146+
# without a fail-under gate: per-platform coverage totals differ,
147+
# and the 70% gate stays on the ubuntu quality job.
148+
run: |
149+
uv run pytest tests --cov=noah_code --cov-report=term \
150+
-W error::pytest.PytestUnraisableExceptionWarning
151+
114152
- name: Verify command entry point
115153
run: uv run noah --version
116154

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ jobs:
5353
sudo apt-get install -y ripgrep
5454
rg --version
5555
56+
- name: Type check
57+
run: uv run mypy src/noah_code
58+
5659
- name: Run release checks
5760
run: |
5861
uv run ruff check src tests

.pre-commit-config.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,16 @@ repos:
66
hooks:
77
- id: ruff-check
88
args: [--fix]
9+
10+
- repo: local
11+
hooks:
12+
- id: mypy
13+
name: mypy
14+
entry: uv run mypy
15+
language: system
16+
pass_filenames: false
17+
- id: uv-lock-check
18+
name: uv lock --check
19+
entry: uv lock --check
20+
language: system
21+
pass_filenames: false

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ Type `/` to search the full command and configuration reference. The most common
123123
| `Shift+Enter` | Insert a newline |
124124
| Drag, then `Cmd+C` / `Ctrl+Shift+C` | Select and copy TUI text |
125125
| `Ctrl+Shift+C` with no selection | Copy the latest Noah reply |
126+
| `Ctrl+G` | Open the searchable skills picker |
127+
| `Ctrl+]` | Return to live transcript output |
126128
| `Tab` | Switch between build and plan mode |
127129
| `F2` | Open execution activity |
128130
| `F3` | Open paginated conversation history |

docs/configuration.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ Inspect the resolved configuration from the CLI or inside an interactive session
215215
lists every nested path, while an optional path scopes the output. Values whose names look like
216216
credentials are redacted.
217217

218+
Configuration is validated strictly: unknown keys (including typos such as `theem` under `[ui]`)
219+
and invalid values or TOML syntax fail every command with a one-line error naming the file and
220+
field, never a traceback. Fix the named key or remove it; `noah doctor` reports the same error.
221+
218222
```bash
219223
noah config show .
220224
```
@@ -297,14 +301,19 @@ limits.
297301
| `plan` | Reads are allowed; file edits and mutating shell commands are denied. `self.plan.write` may pin `.noah-code/plan.md` |
298302

299303
Switch modes with `--mode`, `/mode build`, or `/mode plan`. The agent can propose a switch with
300-
`self.plan.enter()` / `self.plan.exit_to_build()` after writing a plan. The active mode is stored with the
301-
session.
304+
`self.plan.enter()` / `self.plan.exit_to_build()` after writing a plan; the transition always asks
305+
for confirmation and is never auto-approved, so in `noah run --auto` it is refused and the session
306+
stays in plan mode. The active mode is stored with the session.
302307

303308
Permission rules are evaluated in order, and the last matching rule wins. The default policy:
304309

305310
- Allows ordinary reads.
306-
- Denies likely secrets, including `.env` variants, private keys, `.git` internals, and session
307-
databases. `.env.example` remains readable.
311+
- Denies likely secrets, including `.env` variants, private keys, `.git` internals, credential
312+
stores (`.npmrc`, `.pypirc`, `.netrc`, `.pgpass`, `.kube/config`, `.docker/config.json`,
313+
`.aws/credentials`), Java/JCEKS key stores, and session databases. `.env.example` remains
314+
readable. The same denials apply to Git object syntax (`git show HEAD:.env`) and to patch-output
315+
Git commands with no path scope (`git log -p`, bare `git show`), which ask instead of being
316+
auto-approved.
308317
- Allows workspace edits, read-only in-workspace shell inspection, web reads, skills, and
309318
delegated tasks without interrupting the turn.
310319
- Asks before arbitrary shell execution, external paths, MCP access, and remote mutations.
@@ -322,10 +331,12 @@ Permission rules are evaluated in order, and the last matching rule wins. The de
322331

323332
`--auto` changes routine ask decisions to allow but never overrides an explicit deny.
324333
Elevated-risk commands such as file removal, downloads, and package installation still require
325-
explicit approval. Compound shell commands and mutating or unrecognized Git commands cannot be
326-
silently auto-approved. Interpreters, eval/source commands, and indirect execution wrappers, plus
327-
arguments hidden behind variable, command, ANSI-C, or brace expansion, are denied under `--auto`;
328-
run without `--auto` when one of these commands needs explicit approval.
334+
explicit approval: interactively they ask, and in non-interactive `noah run --auto` they are
335+
rejected outright instead of being silently approved. Compound shell commands and mutating or
336+
unrecognized Git commands cannot be silently auto-approved. Interpreters, eval/source commands, and
337+
indirect execution wrappers, plus arguments hidden behind variable, command, ANSI-C, or brace
338+
expansion, are denied under `--auto`; run without `--auto` when one of these commands needs
339+
explicit approval.
329340

330341
## Installation and updates
331342

docs/development.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ uv run pytest tests
1212
uv build
1313
```
1414

15-
The default test suite is hermetic and does not require network access or provider keys. A single
15+
The default test suite is hermetic and does not require network access or provider keys; pytest-socket
16+
blocks network sockets outright (only local AF_UNIX sockets, e.g. asyncio's self-pipe, stay open). A single
1617
opt-in test performs a live HTTP fetch; run it explicitly on a connected machine:
1718

1819
```bash
@@ -25,7 +26,7 @@ Measure coverage (a 70% total gate runs in CI):
2526
uv run pytest tests --cov=noah_code --cov-report=term-missing
2627
```
2728

28-
Optional git hooks mirror the lint gate:
29+
Optional git hooks mirror the lint, type-check, and lockfile gates:
2930

3031
```bash
3132
uvx pre-commit install
@@ -37,16 +38,19 @@ tool output, and estimated cost.
3738
## CI
3839

3940
GitHub Actions runs the complete test suite on Python 3.12 and 3.13, plus platform smoke tests on
40-
Linux and macOS for arm64 and x86_64. Every pull request and push to `main` must pass lint, static
41-
type checking (mypy), tests with a 70% coverage floor, lockfile validation, and a package build.
42-
Concurrent runs for the same reference are cancelled automatically.
41+
Linux and macOS for arm64 and x86_64. Every pull request, push to `main`, and `v*` tag must pass lint,
42+
static type checking (mypy), tests with a 70% coverage floor (measured on Ubuntu; the macOS smoke jobs
43+
also collect coverage, without a gate, to include mac-only paths), lockfile validation, and a package
44+
build. A separate job runs the network-dependent integration tests and is allowed to fail, since live
45+
network calls can flake for reasons unrelated to the code. Concurrent runs for the same reference are
46+
cancelled automatically.
4347

4448
## Releases
4549

4650
A `v*` tag starts the release pipeline, which:
4751

4852
1. Verifies that the tag, `pyproject.toml`, and package versions match.
49-
2. Reruns tests and builds the wheel and source distribution.
53+
2. Reruns lint, type checks, and tests, then builds the wheel and source distribution.
5054
3. Validates distribution metadata and generates SHA-256 checksums.
5155
4. Publishes to PyPI using short-lived OIDC credentials.
5256
5. Creates provenance attestations and a GitHub release with the artifacts.

docs/extensions.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ test.
2020
```
2121

2222
Invoke it as `/fix the parser`. Commands support `$ARGUMENTS` and positional placeholders `$1`
23-
through `$9`. Front matter may also select a mode or model.
23+
through `$9`. Front matter may also select a mode or model — but only for trusted user commands:
24+
`mode` and `model` front matter in repository commands is ignored, so a checkout cannot switch the
25+
session out of plan mode or onto another model without confirmation.
2426

2527
## Subagents
2628

@@ -57,7 +59,7 @@ configuration owned by the user.
5759

5860
## Skills
5961

60-
Open the dedicated searchable picker with `/skills` or `Ctrl+K`. Selecting a document skill
62+
Open the dedicated searchable picker with `/skills` or `Ctrl+G`. Selecting a document skill
6163
inserts `$skill-name ` into the composer so you can add the task directly. Noah reads the standard
6264
`SKILL.md` directory format used by Codex and Claude, including companion `scripts/`, `references/`,
6365
and `assets/` folders.

docs/interactive-reference.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
| `Shift+Enter` | Insert a newline without sending |
99
| `Tab` | Toggle `build`/`plan` mode; accept the highlighted slash option while suggestions are open |
1010
| `Ctrl+P` | Open the command palette |
11-
| `Ctrl+K` | Open the searchable skills picker |
11+
| `Ctrl+G` | Open the searchable skills picker |
1212
| `Ctrl+O` | Open the session picker |
1313
| `Ctrl+N` | Start a new session |
1414
| `Ctrl+C` | Cancel the active turn and clear queued follow-ups; press twice while idle to quit |
1515
| `Ctrl+Q` | Quit |
1616
| `F1` or `?` | Show help |
1717
| `F2` | Open recent activity and full captured output |
1818
| `F3` | Open paginated persisted conversation history |
19-
| `End` | Return to live transcript output and clear the new-output counter |
19+
| `Ctrl+]` | Return to live transcript output and clear the new-output counter |
2020

2121
At an approval prompt, press `1` to approve once, `2` to remember the approval for the current
2222
session, or `3`/`Esc` to reject it.
@@ -41,8 +41,9 @@ their transcript normally.
4141

4242
Drag across transcript, activity, diff, or history text to select it. `Cmd+C` on macOS or
4343
`Ctrl+Shift+C` in other terminals copies the selection; when there is no selection, the same
44-
shortcut copies Noah's latest reply. `Ctrl+C` also copies an active selection before falling back
45-
to cancel-turn behavior. Selection uses a high-contrast highlight in every Noah theme.
44+
shortcut copies Noah's latest reply. Inside the composer, `Cmd+C`/`Ctrl+C` copies the composer's
45+
own selection; `Ctrl+C` with no composer selection keeps its cancel-turn behavior. Selection uses a
46+
high-contrast highlight in every Noah theme.
4647

4748
### Mid-turn follow-ups
4849

docs/releases/v0.2.4.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Noah Code v0.2.4
2+
3+
This release is a security and reliability hardening pass across the permission engine, checkpoint
4+
capture, workspace edits, budgets, and the terminal interface, plus dependency and CI updates.
5+
6+
## Security
7+
8+
- Closed a hard secret-deny bypass through Git object syntax: `git show HEAD:.env` (and `:path`
9+
forms generally) is now classified by the path component and denied like `cat .env`, in build,
10+
plan, and `--auto` modes alike.
11+
- Unscoped patch-output Git commands (`git log -p`, bare `git show`/`git diff`) no longer ride the
12+
read-only auto-allow bump; they ask, since they can dump committed secrets without naming a path.
13+
- Expanded secret classification to common credential stores: `.npmrc`, `.pypirc`, `.netrc`,
14+
`.pgpass`, `.envrc`, `.kube/config`, `.docker/config.json`, `.aws/credentials`, and Java/JCEKS
15+
key stores.
16+
- Joined short-flag values are now scanned as paths, so `grep -f/etc/passwd`, `rg -f.env`, and
17+
`tail -F~/log` can no longer evade the external-path and secret checks.
18+
- Git checkpoint capture no longer stages secret-classified paths into `refs/noah-code/checkpoints/*`
19+
and no longer executes repository clean filters — capture uses filter-free plumbing
20+
(`hash-object --no-filters` + `update-index --cacheinfo`), preserving executable bits and storing
21+
symlinks as links.
22+
- Non-interactive `noah run --auto` now rejects elevated-risk commands (file removal, downloads,
23+
`find -delete`/`-exec`, and similar) instead of silently approving them; interactive behavior is
24+
unchanged.
25+
- Plan mode can no longer be crossed without confirmation: `mode`/`model` front matter in
26+
repository custom commands is ignored (user commands keep it), and `plan.exit_to_build()` is
27+
never auto-approved.
28+
- `find -delete`/`-exec`/`-execdir` joined the elevated-risk floor, and `rg --hostname-bin` is no
29+
longer treated as read-only.
30+
31+
## Reliability and correctness
32+
33+
- The main edit path (`replace`/`edit`) is now atomic and newline-preserving: byte-level splice,
34+
exactly-one-match enforcement, and temp+fsync+rename writes — CRLF files no longer flip to LF
35+
when one hunk changes, and a crash mid-edit cannot leave a truncated file.
36+
- Reads are bounded: whole-file reads above `max_file_bytes` require an explicit line range, ranged
37+
reads stream instead of loading the file, binary files fail cleanly, and durable pre-images above
38+
`undo_blob_limit` degrade to hash-only instead of ballooning the session database.
39+
- Background-job durable logs rotate at 4 MB keeping the newest lines, and log paging tail-reads
40+
instead of loading the whole file.
41+
- Live model switches (`/model`, `/reasoning`, `/providers use`) now rebuild the full retry and
42+
fallback pipeline — switches no longer silently drop transient retries and fallback models.
43+
- The WAIT path can no longer hang: background-job terminal events always fire even if the runtime
44+
write fails, and the host wait is bounded with state re-checks; a job finishing between WAIT and
45+
wake no longer fails the run spuriously.
46+
- Cost budgets work: per-response cost is recovered from provider-reported cost or LiteLLM's
47+
pricing table, so `max_cost_usd` enforces real charges and `/tokens` reports real cost instead of
48+
`$0.000000`.
49+
- Ctrl+C under `asyncio.run` exits cleanly with the designed double-press flow instead of a
50+
traceback; one press at an idle prompt hints, a second exits, and mid-turn cancellation renders
51+
once.
52+
- Configuration is validated strictly: unknown keys and invalid values or TOML fail every command
53+
with a one-line error naming the file and field — no more tracebacks from `noah doctor`.
54+
- `noah run` no longer exits 0 without doing anything when an auto-update installs; non-interactive
55+
runs print the update notice and proceed.
56+
- Fixed atomic-patch rollback ordering (fsync failure no longer strands a renamed file), new-file
57+
permissions (umask default instead of `0600`), grep-harvested edit anchors after `cd`,
58+
stale-anchor detection without a prior read, double-`close()` wiping the undo journal, stale
59+
interrupted runs resurrecting, and checkpoint metadata clobbering newer session metadata.
60+
61+
## Interface
62+
63+
- Fixed priority keybindings that hijacked basic editing keys: Skills picker moved to `Ctrl+G`,
64+
return-to-live moved to `Ctrl+]`, and `End`/`Ctrl+K` work normally inside the composer again.
65+
- `Cmd+C`/`Ctrl+C` in the composer copies the composer's own selection; keyboard (shift-arrow)
66+
selections are copyable.
67+
- `@`-mention suggestions no longer rescan the whole workspace per keystroke: directory pruning
68+
plus a short-lived cache keep the composer responsive in large repositories.
69+
- One Ctrl+C now renders a single "turn cancelled" entry, and question-modal "Other" collects a
70+
free-text answer instead of submitting the literal string `other`.
71+
72+
## Packaging and CI
73+
74+
- LiteLLM re-pinned to `>=1.96.0,<1.99.0` (resolved 1.98.0): upstream restored macOS wheels, so
75+
provider and security fixes from 1.92–1.98 now reach installs.
76+
- Added upper bounds for end-user installs (`textual`, `pydantic`, `click`, `rich`, `PyYAML`,
77+
`packaging`), which resolve fresh and ignore `uv.lock`.
78+
- Unified the duplicated dev-dependency declarations into the single `dev` extra.
79+
- The default test suite is now hermetic by construction (pytest-socket blocks network sockets);
80+
CI runs on `v*` tags, type-checks in the release pipeline, collects macOS coverage, and runs the
81+
network integration tests as a non-blocking job.
82+
- Expanded the hermetic suite to 565 passing tests.
83+
84+
## Upgrade
85+
86+
Existing managed installations can run:
87+
88+
```bash
89+
noah update
90+
```
91+
92+
New installations can use the one-command installer from the README.
93+
94+
**Full changelog:** https://github.com/skundu42/noah-code/compare/v0.2.3...v0.2.4

docs/reliability.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ on the next launch. This protection complements the persistent edit journal used
5959
Git checkpoints are enabled by default. Noah captures them at turn boundaries and before mutating
6060
shell commands, stores them under `refs/noah-code/checkpoints/<session>/`, and keeps the newest 50
6161
by default. Capturing uses a temporary Git index and does not move `HEAD` or disturb the user's
62-
index. Inspect and restore them with:
62+
index. Capture is filter-free plumbing (`hash-object --no-filters` + `update-index --cacheinfo`),
63+
so repository clean filters never execute during capture, and paths the permission engine
64+
classifies as secrets (`.env`, key stores, credential files) are never staged into checkpoint
65+
refs. Inspect and restore them with:
6366

6467
```text
6568
/checkpoints
@@ -110,14 +113,19 @@ The defaults favor long-running work while keeping growth finite:
110113
| --- | ---: | --- |
111114
| `processes.max_jobs` | `8` | Maximum managed background jobs |
112115
| `processes.max_runtime_seconds` | `86400` | Per-job runtime ceiling (24 hours) |
116+
| Background-job log cap | `4000000` bytes | Durable per-job JSONL log rotates, keeping the newest lines |
117+
| `max_file_bytes` | `512000` | Whole-file reads above this require an explicit line range |
118+
| `undo_blob_limit` | `2000000` | Per-file cap for undo/durable pre-images (larger edits are hash-only) |
113119
| `reliability.interaction_timeout_seconds` | `86400` | Approval/question timeout |
114120
| `reliability.artifact_max_bytes` | `2000000000` | Full-output artifact quota per session |
115121
| `reliability.session_max_bytes` | `5000000000` | Total session-storage ceiling |
116122
| `reliability.max_runtime_events` | `20000` | Retained operational events |
117123
| `checkpoints.max_per_session` | `50` | Rolling Git checkpoint count |
118124

119125
Optional `[budget]` limits cap cumulative model tokens, estimated/provider-reported cost, or session
120-
wall-clock time. Counters persist across restarts:
126+
wall-clock time. Cost is computed per response from the provider's reported cost when present,
127+
falling back to LiteLLM's pricing table (`completion_cost`), so `max_cost_usd` is enforced against
128+
real charges. Counters persist across restarts:
121129

122130
```toml
123131
[budget]

0 commit comments

Comments
 (0)