Skip to content

feat(cli): scope protected-file warnings to a project - #440

Open
NoRiceToday wants to merge 10 commits into
mainfrom
NoRiceToday/Reduce-protected-file-noise
Open

NoRiceToday wants to merge 10 commits into
mainfrom
NoRiceToday/Reduce-protected-file-noise

Conversation

@NoRiceToday

Copy link
Copy Markdown
Contributor

Issue: Closes #438

What

  • Batch mid-session protected-file detections into one decision dialog.
  • Add exact-path, batch, and project-scoped basename exceptions.
  • Keep launch-time protection and the audit trail unchanged.

Why

Creating a worktree can generate a burst of protected-pattern files such as .env and .env.local. omac cannot tell whether they are local defaults or real secrets, so it warns. The old watch produced one popup per file; an exact path does not cover the next worktree, and a global basename exception would silence that name in every project. A project-scoped exception is the narrowest scope that covers future worktrees of the same repository.

How

  • Resolve the project root by walking to the first .git entry; a validated linked worktree maps to the main repository. core.worktree is never read.
  • Store {root, name} in ~/.config/omac/prefs.yaml; match only below the canonical root.
  • Offer the project option last, after keep, exact paths, and the batch.
  • Refuse / and $HOME as roots. Non-git projects and worktrees outside the repository keep exact-path and batch choices.

Diagram

flowchart TD
  W[workdir] --> G{first .git entry}
  G -- directory --> R[parent = repo root]
  G -- file --> V{validated linked worktree?}
  V -- yes --> M[parent of common dir = main repo]
  V -- no --> L[directory holding .git = repo root]
  R --> C{contains workdir and<br/>not / or $HOME?}
  M --> C
  L --> C
  C -- yes --> P[project option offered]
  C -- no --> N[no project option]
Loading

Verification

  • go build ./... — pass
  • go vet ./internal/cli ./internal/prefs ./internal/netprompt ./internal/sandboxrun — pass
  • go test ./internal/prefs ./internal/netprompt ./internal/pathutil — pass
  • go test -race ./internal/cli -run 'Protected|GitProjectRoot|PathUnder|AskExceptions' — pass
  • go test ./internal/sandboxrun -run 'Worktree|Grant' -skip 'Integration' — pass
  • Full internal/cli still fails only on pre-existing sandbox limits (/tmp, ~/.config/omac).

Follow-up

Revoking a project exception still means editing prefs.yaml.

Sajjad Ahmad added 9 commits November 3, 2026 11:47
Detections from one scan arrive as one decision dialog instead of one
popup per file. The dialog offers, narrow to broad: ignore this exact
file, ignore all files in this batch, or ignore every file with that
basename in all projects. "Keep warning" is the default. Exceptions are
stored in ~/.config/omac/prefs.yaml; files stay protected at launch,
only the repeated notice is skipped.

The session-end report collapses a burst to one summary line, --verbose
lists every path, and the audit log keeps one event per path.

Closes #438

🤖 Generated with DeepSeek V4.1 Flash


Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Captures the original noise requirements, the exact-path and batch
baseline from #438, the rejected global basename option, and a design
for a git-root-scoped name exception. No implementation yet; the tasks
list the follow-up work.

Refs #438

🤖 Generated with DeepSeek V4.1 Flash


Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
A worktree created under .worktrees/<name> brings new .env files with a new path, so an exact-path exception never covers the next worktree. Add one broader dialog choice: ignore a basename inside the repository.

The root comes from git common dir, which is shared by every worktree of the repo, and is accepted only when it contains the workdir. That keeps the scope off other projects and off a forged .git. Global basename exceptions stay out: .env.local often holds real secrets.

The file stays protected at launch and every detection is still audited. Exact-path and batch choices are unchanged.


Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
core.worktree lives in a config file the sandboxed agent can write, and
git rev-parse --show-toplevel honours it. A forged .git pointer could
therefore scope a project exception to a parent tree that holds other
checkouts.

Discover the root by walking up to the first .git entry instead. A .git
directory roots at its parent. A .git file roots at the main repository
only when sandboxrun.ResolveWorktreeCommonDir validates the worktree
back-pointer and layout; a submodule, separate git dir, or forged gitfile
stays at the directory that holds the file. The containment check plus
the root/home/shallow-ancestor gates reject every broader scope.

Lift the duplicated canonicalisation and containment helpers into
internal/pathutil.

Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Three related defects in the mid-session watch:

- A broken prefs.yaml was silently replaced with an empty store, dropping
  every stored preference on the next save. Keep the load error, run the
  watch with an in-memory-only store, and record one notice.
- "Ignore all" rewrote prefs.yaml once per path. Add
  AddProtectedWatchIgnores, which appends the missing paths and saves once.
- Exact-path matching used filepath.Clean, so a symlinked spelling
  re-prompted. Canonicalise both sides through pathutil.

Also document that w.ignored is owned by the watch goroutine, use
slices.DeleteFunc so removePath/removePaths stop writing through the
caller's slice header, note the scan pause in askExceptions, and show
"(8 listed)" when the batch covers more files than the dialog lists.

Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The protected-file dialog can offer fourteen long options. The generic
zenity/kdialog path passed no width or height, so the batch and project
choices could land below the fold.

Pass dialogDimensions() into both backends, add --no-markup to zenity,
and stop Pango-escaping the plain-text kdialog labels. An option that
contains &, <, or > now returns the same string the caller looks up.
Delete the unused Choose helper and its duplicated five-minute timeout;
ChooseContext already has a caller-owned context.

Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The design and tasks still described the rev-parse invocation that
trusted core.worktree. Rewrite the resolution section around the
filesystem walk: a .git directory roots at its parent, a validated
linked worktree at the main repository, and anything else at the
directory holding the .git file. State that core.worktree is ignored and
that the filesystem root, $HOME, and too-shallow ancestors are refused.

Also correct the dialog wording: "Ignore all N" covers every file in the
batch (showing "(8 listed)"), --verbose lists the warnings the user
kept, and the 10-second scan pauses while a dialog is open.

Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The depth gate refused a strict ancestor with fewer than two path components. That hit legitimate layouts such as /repo or /app: the main checkout got the project option, its worktree did not, even though both resolve to the same git root.

Discovery already anchors the root at the directory that owns .git, so the depth heuristic only added a false negative. Keep the two concrete refusals, / and $HOME.


Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
"Keep warning" read like "warn me again soon", but the watch reports each path once per session. The choice saves no exception and keeps the file in the audit log and the session-end report.

Label it "Keep warning for N files (report at exit)" and say in the body and docs what keeping it means.


Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
@NoRiceToday
NoRiceToday force-pushed the NoRiceToday/Reduce-protected-file-noise branch from 6f84638 to 88a74a7 Compare November 3, 2026 10:53
@JonathanRohland-TNG

Copy link
Copy Markdown
Contributor

Thanks for this, the batching, the exact-path exceptions and the one-line summary fix the noise in #438 well. I'd like to suggest that we simplify how the project scope is chosen before we merge:

Problem: the git-based root can be wider than the project

resolveGitProjectRoot maps a linked worktree to filepath.Dir(common). Two cases go wrong (both reproduced locally on 88a74a7):

  1. Bare repo with worktrees. Layout ~/repos/shop.git (bare) + ~/repos/shop-main (worktree). The common dir is shop.git, so the root becomes ~/repos. "Ignore every .env in this project (repos)" then silences .env in every repo under ~/repos.

      ~/repos/                    ← root (!)
      ├── other-project/          ← now "in the project"
      ├── yarp3/                  ← now "in the project"
      ├── shop.git/               ← common dir (bare)
      │   └── worktrees/
      │       └── shop-main/
      │           ├── gitdir
      │           └── commondir   "../.."
      └── shop-main/              ← workdir
          ├── .git                "gitdir: ~/repos/shop.git/worktrees/shop-main"
          └── .env
    

    Repro with plain git:

    git init --bare ~/repos/shop.git
    git -C ~/repos/shop.git worktree add --orphan ~/repos/shop-main
    # resolveGitProjectRoot("~/repos/shop-main") → "~/repos", true
  2. Forged worktree metadata. The agent can write .git in the workdir. It can create a .git file plus a matching <x>/worktrees/<name> admin dir with a valid back-pointer, so ResolveWorktreeCommonDir accepts it and the root moves to the workdir's parent. The agent wrote both sides of each check, so every check passes. If the user clicks the project option, the exception covers sibling projects.

      ~/repos/                    ← root (!)
      ├── other-project/          ← now "in the project"
      ├── yarp3/                  ← now "in the project"
      └── proj/                   ← workdir = fake common dir
          ├── .git                "gitdir: ~/repos/proj/worktrees/n"
          ├── worktrees/
          │   └── n/              ← fake admin dir
          │       ├── gitdir      "~/repos/proj/.git"
          │       └── commondir   "../.."
          └── .env.local
    

    The root is resolved on the first dialog, so the agent can plant this any time before then.

Both pass the / and $HOME gates, and the label only shows the folder name, so the user can't tell.

Proposal: let the current omac workspace folder define the scope

The case in #438 is a worktree created under yarp3/.worktrees/<name> while a session runs in yarp3. The watch scans the workdir recursively, so that session sees the burst. Every affected file is a descendant of the folder omac was started in.

So instead of deriving a root from git metadata:

  • Offer "Ignore every <name> under <full path>" with the session workdir as the path.
  • Optionally also offer the workdir's parent folders, so a session started inside .worktrees/A can pick yarp3. Candidates come from the workdir path string only, which the agent cannot change.
  • Keep refusing / and $HOME, and show the full path in the label.
  • Store {root, name} in ~/.config/omac/prefs.yaml as now; matching stays a prefix check under root.

This removes gitroot.go and its tests, needs no .git parsing, and closes both cases above. Trade-off: worktrees outside the project folder (bare layouts, ../repo.worktrees, Orca's default ~/orca/workspaces) are not covered by one exception. The current approach also refuses worktrees outside the repo, so we don't lose anything there.

Why I'm suggesting this

It's a simpler solution, and I'm not aware of a relevant case it doesn't cover. If there is a reason to derive the common folder from git metadata, it isn't obvious to me, so please tell me if I'm missing one. Otherwise I'd go with the simpler design. It's easier to reason about, and it leaves fewer chances for edge cases nobody anticipated. The two cases above would be two examples of non-obvious behavior.

Other findings – Independent of the scope question

  • Concurrent sessions lose prefs.yaml updates. The store is loaded once and saved later, so a second session's exceptions (and suppress_plugin_warning) can be overwritten. Re-load before Save and merge.
  • Dialog timeout on macOS doesn't match the intended behavior. askExceptions returns silently on DeadlineExceeded ("the user did not answer"), but chooseDarwin returns signal: killed instead, so macOS falls through to the no-backend alert while Linux stays silent. Either return ctx.Err() in chooseDarwin like chooseZenity does, or make the alert after a timeout intentional on all platforms.

@JonathanRohland-TNG JonathanRohland-TNG self-assigned this Nov 5, 2026
Agent-writable Git metadata must not select a warning exception scope.
Offer the launch folder and explicit parent choices with full paths.

Merge concurrent preference updates under a shared file lock and return
context errors when a macOS decision dialog times out.

Refs #438

Assisted-by: OpenAI GPT-6.1 Sol
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
workdir string
projectRoot string
projectRootTried bool
// workdir is fixed at launch. Folder choices come only from this path,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: This comment doesn't really make sense without the context of the discussion on this PR.

@JonathanRohland-TNG

JonathanRohland-TNG commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

After the latest commit, the flowchart in the PR description doesn't match the implemented behavior anymore.
I would suggest to adjust it before merging, to make it clear what was implemented.

The same is true for the openspec files, which is actually more important (since these might influence future agents).

@JonathanRohland-TNG

JonathanRohland-TNG commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

From a UX perspective, I find that this dialog is quite a wall-of-text and contains too much technical details, which makes it hard to parse what it is actually trying to ask me:
CleanShot 2026-11-07 at 9 25 48 PM@2x

My suggestion

The following 2 files matching file-paths that are denied by omac were created during this session. They remain readable by the agent until omac restarts:
<list of file paths>

If you do not want to be notified about this every time, you can suppress this warning for the filenames or paths in the future. Please choose how you want to proceed:

- Keep warning every time (default)
- Suppress warnings for this path permanently: /Users/rohlandj/repos/tng-agent-tooling/pr440-dialog-test/yarp3/ worktrees/feat-a/.env
- Suppress warnings for this path permanently: /Users/rohlandj/repos/tng-agent-tooling/pr440-dialog-test/yarp3/.worktrees/feat-a/.env.local
- Suppress warnings for all 2 files permanently
- Suppress warnings for every file .env under /Users/rohlandj/repos/tng-agent-tooling/pr440-dialog-test/yarp3
- Suppress warnings for every file .env.local under /Users/rohlandj/repos/tng-agent-tooling/pr440-dialog-test/yarp3

I also removed the option to put the ignorance-root from the options-list (love this word ;)) higher than the workspace-folder, because I don't see the use case for this and I found the number of options overwhelming. If you have a need for this, I don't mind adding it back.

It's also quite unfortunate that the dialog doesn't allow the user to select multiple options, so they can only choose to handle one file or all if them, but nothing in-between.

EDIT: Changed "Ignore for..." to "Suppress warnings for..." to make it clear what ignoring means.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mid-session protected-file watch floods the user with one dialog and one end-of-session line per new .env

2 participants