Skip to content

fix(app): withhold destructive commands from auto-approve - #94

Open
simota wants to merge 4 commits into
mainfrom
fix/auto-approve-hardening
Open

simota wants to merge 4 commits into
mainfrom
fix/auto-approve-hardening

Conversation

@simota

@simota simota commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Auto-approve answered Codex and agy command dialogs no matter what they ran, which contradicts FR-11 ("never approve dangerous operations"). This PR adds a denylist for those dialogs and fixes two state bugs found along the way.

Four commits, each green on its own:

  1. Breaker re-enable. The runaway breaker latched disabled_by_runaway in the io thread, and only a scan that saw the mode off cleared it. Turning the mode back on before the next pty output therefore left the pane suppressed for good. The tab flag is already the breaker's off switch: the main thread clears it in the same step that sends the approval that reaches the limit. The latch is dropped, and the io state starts over on that feedback.
  2. Command denylist (auto_approve/denylist.rs). The displayed command is checked before a Codex or agy command dialog is answered. A hit leaves the dialog for the user, as if the mode were off. The command is read from the grid, so the check has to survive what the grid loses:
    • Wraps: a row boundary may be a wrap between words or inside one (rm - / rf). The two words meeting at a boundary are kept apart and are also tried glued, which also handles both kinds of wrap mixed in one command. The check stays linear in the paragraph.
    • Quoting: words are split twice, once shell-quoted and once plainly, so -C "/tmp/my project", | 'bash' and sh -c 'rm -rf …' are all seen.
    • Position: rules look for flags and git subcommands anywhere after the program, not at fixed positions (git clean target -f, git -C dir reset --hard). Programs are matched by basename (/bin/rm).
    • Scope: only the rows above the dialog's choices are checked, because the choices only echo the command.
  3. Stale agent status. An agent that exited without its end-of-session report left its last explicit status on the card. The status is now cleared when the foreground process leaves recognized agents. A switch straight from one agent to another keeps the status, because process changes post only on change, and the new agent's first report can land before the poll that sees it. The clearing goes through the same Overview label invalidation that an AgentStatus delta uses.
  4. Follow-up marker. #TODO(agent): UNVERIFIED at the signature table. The Claude Code anchors were written against synthetic fixtures, and no real Claude permission dialog has been captured to confirm them.

Reviewer notes:

  • Every ambiguity resolves toward denying. For example, | grep sh is denied, and so is a destructive command that appears only inside a commit message. A false hit costs one manual approval, but unattended Codex runs will stop more often.
  • The denylist is best-effort. It sees only what the dialog paints. A command that an agent elides (agy's ⋯ (n lines hidden)) or wraps inside a script can still pass. The specs record this (docs/specs/auto-approve-mode.md, docs/specs/agent-workflow.md).
  • A hit raises no notification of its own. The dialog simply waits, and NOA_AUTO_APPROVE_TRACE=1 logs the rule that matched.
  • Known gap: when one agent replaces another and the new agent never reports, the previous agent's status remains on the card.

Validation:

  • Unit tests cover each rule, ordinary commands that must pass, wrapped and mixed-wrap splits, quoting, absolute paths, and git global options.
  • On 48-column VT grids, tests reproduce the review cases end to end (rm - / rf, and git / reset <sha> - / -hard). Each has a harmless control on the same layout that must still fire. The mid-token grid test was confirmed to fail without the glued-word handling.
  • Session-store tests cover agent → shell (status cleared), shell → agent, and agent → agent (status kept).
  • Worst case is about 0.6 ms per check for a 30-row paragraph in a debug build. An earlier version that enumerated every combination of wraps took about 42 ms and was replaced.
  • cargo test -p noa-app and cargo fmt --all -- --check pass on each commit. cargo clippy -p noa-app --all-targets adds no warnings in the touched files.
  • cargo test --workspace passes everywhere except noa-pty and noa-ipc, which need unsandboxed device and socket access. Those crates are untouched and were not run outside the sandbox.

Not addressed:

  • The Overview invalidation on a status-clearing process change was checked by reading the code only. There is no App-level test harness for apply_session_delta.
  • Claude Code auto-approve still relies on unverified screen anchors (commit 4). Moving it to the PermissionRequest hook is a separate decision.
  • Acceptance by a running Codex or agy CLI (spec AC-13) was not exercised.

simota added 4 commits October 2, 2026 07:41
The runaway breaker latched `disabled_by_runaway` in the io thread, and
only a scan that saw the mode off cleared it. Turning the mode back on
before the next pty output therefore left the pane suppressed for good.

The tab flag, which the main thread clears in the same step that sends
the limit-reaching approval, is already the breaker's off switch, so
drop the latch and start the io state over on that feedback.
Codex and agy command dialogs were approved whatever they ran, which
contradicts FR-11 ("never approve dangerous operations"). Check the
displayed command against a fixed denylist and leave a matching dialog
for the user, as if the mode were off.

The command is read off the grid, so the check has to survive what the
grid loses: a row boundary may be a wrap between words or inside one,
so words meeting at a boundary are tried both apart and glued; words
are split both shell-quoted and plainly; and rules look for flags and
git subcommands anywhere after the program rather than at fixed
positions. Every ambiguity resolves toward denying, since a false hit
costs only a manual approval.
An agent that exits without its end-of-session report (crash, kill)
left its last explicit status on the card, so the sidebar and Overview
kept showing it as running or waiting.

Clear the status when the foreground leaves recognized agents. A switch
from one agent straight to another keeps it: process changes post only
on change, so the arriving agent's first report can land before the
poll that sees it. Route the clearing through the Overview label
invalidation an AgentStatus delta already uses.
The Claude Code prompt anchors were written against synthetic fixtures,
and no real Claude permission dialog has been captured to confirm them.
Leave the follow-up at the table so it is not lost.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-02T01:07:07.291797Z 967ba92 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 967ba92d04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"push" if after.iter().any(|arg| forced(arg) || arg.starts_with('+')) => {
"git push --force"
}
"reset" if after.contains(&"--hard") => "git reset --hard",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject abbreviated destructive Git options

Git accepts unique long-option abbreviations, but this exact comparison allows git reset --har HEAD to be auto-approved even though Git executes it as --hard. I confirmed this behavior with the installed Git, and git reset -h describes --hard as resetting HEAD, the index, and the working tree. Similar accepted abbreviations such as GNU rm --rec also bypass the exact --recursive check, so the denylist must account for abbreviations before treating these commands as safe.

Useful? React with 👍 / 👎.

Comment on lines +175 to +179
fn agent_left(previous: Option<&str>, current: Option<&str>) -> bool {
let is_agent = |process: Option<&str>| {
process.is_some_and(|process| classify_agent(process) != AgentKind::Generic)
};
is_agent(previous) && !is_agent(current)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve agent status across inconclusive process polls

When a macOS foreground-process poll transiently returns None, or argv lookup for a node/bun-hosted agent temporarily falls back to the generic wrapper name, this predicate treats that inconclusive result as proof that the agent exited and clears its explicit status. The next successful poll changes the process back to the recognized agent but deliberately preserves the now-empty status, so a one-off polling failure can erase a pending Permission/Input report until the agent emits another hook update. Only a confirmed transition to a known non-agent, or a suitably debounced failure, should clear the status.

Useful? React with 👍 / 👎.

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.

1 participant