Skip to content
Merged
Show file tree
Hide file tree
Changes from 70 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
fe75ea8
feat(security): trusted-roots filesystem access model + enforcement
sanil-23 May 21, 2026
66c083b
feat(security): hot-swappable live SecurityPolicy + autonomy-changed …
sanil-23 May 21, 2026
e2b8e43
feat(config): get/update_autonomy_settings RPC for agent access mode
sanil-23 May 21, 2026
b3a164e
feat(tools): detect_tools + install_tool + Windows PowerShell shell
sanil-23 May 21, 2026
d5cdd3b
feat(agent): install live policy + inject host-access context at sess…
sanil-23 May 21, 2026
2cefae9
feat(app): Agent access settings panel + autonomy RPC client
sanil-23 May 21, 2026
ae4fddd
docs(about_app): catalog agent access mode + document [autonomy] knobs
sanil-23 May 21, 2026
f7e5c3c
feat(security): Full access mode bypasses the command allowlist
sanil-23 May 22, 2026
821328b
feat(security): narrow high-risk command set to catastrophic-only
sanil-23 May 22, 2026
1df3820
feat(security): fail-closed command classifier + harness gate decision
sanil-23 May 22, 2026
690f89b
feat(tools): route executors through the harness approval gate
sanil-23 May 22, 2026
a701928
feat(tools): gate write git operations through the approval gate
sanil-23 May 22, 2026
f75951e
feat(security): route package installs to an always-ask Install bucket
sanil-23 May 22, 2026
0e128f1
feat(security): cross-platform unconditional path hardening
sanil-23 May 22, 2026
467a9ae
feat(security): LLM escalate-only command category
sanil-23 May 22, 2026
efa2185
feat(config): default ~/OpenHuman/projects read-write projects home
sanil-23 May 22, 2026
88b2715
feat(app): relabel agent access tiers + full-access warning
sanil-23 May 22, 2026
f5d7308
docs(CLAUDE.md): document the deterministic command permission model
sanil-23 May 22, 2026
5cdbd87
docs(CLAUDE.md): correct approval-gate status (flag-gated, not absent)
sanil-23 May 22, 2026
82b9100
Merge remote-tracking branch 'upstream/main' into feat/agentic-runtime
sanil-23 May 22, 2026
2320b27
feat(approval): thread chat context into the gate for reply routing
sanil-23 May 22, 2026
d731a65
feat(approval): chat-native yes/no approval surface + ingress router
sanil-23 May 22, 2026
6fb6d02
style: cargo fmt the agentic-runtime permission changes
sanil-23 May 22, 2026
3f97992
fix(app): agent-access radio never selected — collapse to 3 tiers
sanil-23 May 23, 2026
2399821
feat(approval): gate only interactive chat turns, skip background/cron
sanil-23 May 23, 2026
4c4db08
feat(approval): surface approval_request in chat UI + Approve/Deny
sanil-23 May 23, 2026
5c388c1
fix(approval): register surface subscriber on serve boot, not only in…
sanil-23 May 23, 2026
e942fb7
fix(socketio): stop double-emitting streaming deltas via the colon alias
sanil-23 May 24, 2026
b7faa3f
feat(approval): show the exact command on the card, drop the yes/no n…
sanil-23 May 24, 2026
140e534
fix(socketio): deliver web-channel events once — socketioxide doesn't…
sanil-23 May 24, 2026
4b164ba
feat(agent): circuit-break repeated tool failures instead of looping …
sanil-23 May 24, 2026
2a4fa13
fix(web): rebuild the session agent when the agent-access policy changes
sanil-23 May 24, 2026
6db3dad
fix(app): auto-apply agent access changes (no separate Save button)
sanil-23 May 24, 2026
b4f75a1
fix(security): don't block approved redirects (2>&1) — only true hidd…
sanil-23 May 25, 2026
203ef05
test(web): update SessionCacheFingerprint helper for autonomy_signature
sanil-23 May 25, 2026
5d18ce7
feat(agent): tell code_executor the command-format constraints + when…
sanil-23 May 25, 2026
9b35106
fix(runtime): run shell with bash pipefail so masked pipe failures su…
sanil-23 May 25, 2026
fb863ec
feat(agent): opt-in tool-output logging for debugging (OPENHUMAN_LOG_…
sanil-23 May 25, 2026
47cdc5d
feat(agent): log tool output in run_inner_loop too (subagent coverage)
sanil-23 May 25, 2026
7378328
feat(agent): subagent circuit breaker via shared RepeatFailureGuard
sanil-23 May 25, 2026
e47f453
feat(app): simplify agent-access UI — tier + a single workspace-confi…
sanil-23 May 25, 2026
ae2dcbb
feat(agent): recognizable hard-reject markers so the agent stops reit…
sanil-23 May 25, 2026
e3645d4
fix(agent): tag tool-level read-only blocks with [policy-blocked] too
sanil-23 May 25, 2026
9c4e0b6
test(agent): cover tool-level readonly markers + harness↔gate seam
sanil-23 May 25, 2026
292880b
chore(agent): remove opt-in tool-output debug logging
sanil-23 May 25, 2026
08eb4b3
fix(settings): agent-access tier highlight + radio use primary, not d…
sanil-23 May 25, 2026
ec88ad2
fix(socket): give the core socket.io handshake headroom over high-lat…
sanil-23 May 25, 2026
3a76dd6
Merge upstream/main into feat/agentic-runtime
sanil-23 May 25, 2026
f4f6a68
Merge branch 'feat/agentic-runtime' of https://github.com/sanil-23/op…
sanil-23 May 25, 2026
30ed07a
fix(merge): finish reconciliation + rename Agent access → Agent OS ac…
sanil-23 May 25, 2026
dd3a3f8
fix(security): always grant ~/OpenHuman/projects, not just on channel…
sanil-23 May 25, 2026
f52cd2e
style(i18n): prettier-format the mirrored chat.approval locale chunks
sanil-23 May 25, 2026
0603bea
fix(config): wrap get_autonomy_settings under result (emit a log)
sanil-23 May 25, 2026
e1ef75a
fix(review): address CodeRabbit — breaker coverage, decide errors, i18n
sanil-23 May 25, 2026
d109f1d
i18n(settings): localize the whole AgentAccessPanel (CodeRabbit)
sanil-23 May 25, 2026
7f173dd
fix(shell): audit approved!=allowed — only Prompt-class commands are …
sanil-23 May 25, 2026
2d8076d
feat(approval): enable the approval gate by default (opt out via =0)
sanil-23 May 25, 2026
4619a05
fix(agentic-runtime): clear Rust Core Test failures on #2631
sanil-23 May 25, 2026
54b3f07
Merge remote-tracking branch 'upstream/main' into feat/agentic-runtime
sanil-23 May 25, 2026
e6908ff
style(agent): rustfmt host_runtime test (wrap long assertion lines)
sanil-23 May 25, 2026
aae4368
refactor(socketio): explicit STREAMING_DELTA_EVENTS set; doc file_wri…
sanil-23 May 25, 2026
944e851
fix(approval): gate-flow correctness + stop logging token-derived ses…
sanil-23 May 25, 2026
cd56a9f
fix(tools): install/detect hardening + autonomy schema alignment
sanil-23 May 25, 2026
b857f75
fix(ui): approval-card hardening + autonomy save race
sanil-23 May 25, 2026
3d83364
test(ui): assert localized approval error, not raw RPC text
sanil-23 May 25, 2026
c3dd2d6
Merge remote-tracking branch 'upstream/main' into feat/agentic-runtime
sanil-23 May 25, 2026
2b02306
test(ui): use regex matcher + prettier for the localized approval error
sanil-23 May 25, 2026
51af897
Merge remote-tracking branch 'upstream/main' into feat/agentic-runtime
sanil-23 May 25, 2026
478c2e3
fix(channels): keep channel-provided tools visible under agent tool-s…
sanil-23 May 26, 2026
b562271
test(ui): cover AgentAccessPanel + ApprovalRequestCard fallback for d…
sanil-23 May 26, 2026
1635a32
fix(install_tool): require interactive approval; address review (#2631)
sanil-23 May 26, 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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ PRs must meet **≥ 80% coverage on changed lines**. Enforced by [`.github/workf

**Rust config** uses a TOML `Config` struct (`src/openhuman/config/schema/types.rs`) with env overrides (`src/openhuman/config/schema/load.rs`).

**Agent access mode** — the `[autonomy]` block (`src/openhuman/config/schema/autonomy.rs`) drives the agent's filesystem/shell reach via `SecurityPolicy` (`src/openhuman/security/policy.rs`). Tiers: `level` (`readonly` = read-only / `supervised` = "ask before edit" / `full` = full access) × `workspace_only` × `trusted_roots` (per-folder `read`/`readwrite` grants outside the workspace, overriding `forbidden_paths` for their subtree) × `allow_tool_install` (gates `install_tool`). Edit live via the `config.update_autonomy_settings` RPC or **Settings → Agent access** (`AgentAccessPanel.tsx`); changes swap the process-global policy in `security::live_policy` and apply to new sessions. The default projects home is `~/OpenHuman/projects` (`config::default_projects_dir`, env `OPENHUMAN_PROJECTS_DIR`), auto-created at startup and injected as a ReadWrite trusted root — distinct from the hidden internal `~/.openhuman/workspace`.

**Command permission model (deterministic, fail-closed):** `classify_command` buckets a command into `CommandClass` (`Read` / `Write` / `Network` / `Install` / `Destructive`); an unrecognized command is **`Write`**, never `Read`. `gate_decision(class, tier)` → `Allow` / `Prompt` / `Block`: read-only allows only reads; ask-before-edit prompts every act (file *create* is free, *edit-existing* prompts); full runs read+write but **always-asks** Network/Install/Destructive. Acting tools (`shell`/`node_exec`/`npm_exec`/`file_write`/`edit_file`/`apply_patch`/`git_operations`/`curl`) return `external_effect_with_args() == true` for `Prompt` classes so the harness routes them through the `ApprovalGate` *before* `execute()`; read-only `Block` + structural guards (`check_gated_command`) are enforced in-tool. The LLM may pass a `category` (escalate-only: `max(rust_floor, declared)`). System/credential dirs are an **unconditional** cross-platform block (`is_always_forbidden`, trusted-root-proof). Enforcement is in Rust (`classify_command`/`gate_decision`/`check_gated_command`/`is_path_string_allowed`/`validate_path`), never the system prompt.

> ⚠️ **The approval prompt is ON by default** (opt out with `OPENHUMAN_APPROVAL_GATE=0`/`false`, `jsonrpc.rs`). `ApprovalGate::init_global` installs unless disabled, so `try_global()` is `Some` and the prompt is wired end-to-end; with `OPENHUMAN_APPROVAL_GATE=0` the harness skips the intercept and `Prompt`-class calls **run unprompted**. The gate parks only for **interactive chat turns** (a `tokio` task-local chat context is set in `channels/providers/web.rs`; background triage/cron turns carry no context and are allowed through, not gated). It publishes `DomainEvent::ApprovalRequested`, which `ApprovalSurfaceSubscriber` bridges to the `approval_request` web-channel socket event; the frontend (`ChatApprovalRequestEvent` → `chatRuntime.pendingApprovalByThread` → `ApprovalRequestCard` above the composer) surfaces Approve/Deny, routing to the `openhuman.approval_decide` RPC. A typed `yes`/`no` chat reply is also honoured server-side (web.rs ingress router runs before the "newer request aborts the in-flight turn" path); any other text cancels the parked turn and is taken as a fresh message. Unanswered prompts still park to the 10-min TTL → Deny. Read-only blocking, path hardening, structural guards, and classification **are** live regardless of the flag. Full access ships as documented full-trust (not sandboxed).

---

## Testing
Expand Down
107 changes: 107 additions & 0 deletions app/src/components/chat/ApprovalRequestCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import debug from 'debug';
import React, { useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../services/coreRpcClient';
import { clearPendingApprovalForThread, type PendingApproval } from '../../store/chatRuntimeSlice';
import { useAppDispatch } from '../../store/hooks';
import Button from '../ui/Button';

/**
* Binary v1 decision surface. The backend `approval.decide` RPC also accepts
* `approve_always_for_tool`, but the locked v1 contract is yes / no — a typed
* `yes`/`no` chat reply is the equivalent server-side path.
*/
const log = debug('openhuman:chat:approval-card');

type BinaryDecision = 'approve_once' | 'deny';

interface Props {
threadId: string;
approval: PendingApproval;
}

/**
* Surfaces a `Prompt`-class tool call parked on the ApprovalGate
* (`approval_request` socket event) and routes the user's Approve / Deny to the
* `openhuman.approval_decide` RPC. Rendered above the composer for the active
* thread; clears itself on a recorded decision (the turn-end handlers in
* {@link ChatRuntimeProvider} also clear it if the turn is cancelled).
*/
export const ApprovalRequestCard: React.FC<Props> = ({ threadId, approval }) => {
const { t } = useT();
const dispatch = useAppDispatch();
const [deciding, setDeciding] = useState<BinaryDecision | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);

const decide = async (decision: BinaryDecision) => {
if (deciding) return;
setDeciding(decision);
setErrorMsg(null);
try {
await callCoreRpc({
method: 'openhuman.approval_decide',
params: { request_id: approval.requestId, decision },
});
// Resolve optimistically; ChatRuntimeProvider also clears on turn end.
dispatch(clearPendingApprovalForThread({ threadId }));
} catch (e) {
// Keep raw RPC error detail in namespaced dev logs only; show the user the
// localized fallback — never leak internal error text into the UI.
log('approval_decide failed: %o', e);
setErrorMsg(t('chat.approval.error'));
setDeciding(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

return (
<div
role="alertdialog"
aria-label={t('chat.approval.title')}
className="rounded-xl border border-amber/40 bg-amber/5 p-3 text-sm">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none">
🔒
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-ink">{t('chat.approval.title')}</p>
<p className="mt-1 text-ink-soft break-words">
{approval.message || t('chat.approval.fallback')}
</p>
{approval.command && (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded bg-ink/5 px-2 py-1.5 font-mono text-xs text-ink">
{approval.command}
</pre>
)}
<p className="mt-1 text-xs text-ink-soft">
{t('chat.approval.tool')}{' '}
<span className="font-mono text-ink">{approval.toolName}</span>
</p>

{errorMsg && <p className="mt-2 text-xs text-coral">⚠ {errorMsg}</p>}

<div className="mt-3 flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => void decide('approve_once')}
disabled={deciding !== null}>
{deciding === 'approve_once'
? t('chat.approval.deciding')
: t('chat.approval.approve')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void decide('deny')}
disabled={deciding !== null}>
{deciding === 'deny' ? t('chat.approval.deciding') : t('chat.approval.deny')}
</Button>
</div>
</div>
</div>
</div>
);
};

export default ApprovalRequestCard;
112 changes: 112 additions & 0 deletions app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { callCoreRpc } from '../../../services/coreRpcClient';
import chatRuntimeReducer, {
type PendingApproval,
setPendingApprovalForThread,
} from '../../../store/chatRuntimeSlice';
import ApprovalRequestCard from '../ApprovalRequestCard';

vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));

const THREAD = 't1';
const approval: PendingApproval = {
requestId: 'req-approval-1',
toolName: 'shell',
message: 'Run `shell` — shell (18 bytes of arguments)',
command: 'pip show yfinance',
};

function renderCard() {
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
store.dispatch(setPendingApprovalForThread({ threadId: THREAD, approval }));
const utils = render(
<Provider store={store}>
<ApprovalRequestCard threadId={THREAD} approval={approval} />
</Provider>
);
return { store, ...utils };
}
Comment thread
sanil-23 marked this conversation as resolved.

describe('ApprovalRequestCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders the action summary, exact command, and tool name', () => {
renderCard();
expect(screen.getByText('Approval needed')).toBeInTheDocument();
expect(screen.getByText('Run `shell` — shell (18 bytes of arguments)')).toBeInTheDocument();
// The exact command being requested is shown verbatim.
expect(screen.getByText('pip show yfinance')).toBeInTheDocument();
expect(screen.getByText('shell')).toBeInTheDocument();
});

it('does not nudge the user to reply yes/no (buttons are the input path)', () => {
renderCard();
expect(screen.queryByText(/reply.*yes/i)).not.toBeInTheDocument();
});

it('Approve routes approve_once to approval_decide and clears the pending state', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({});
const { store } = renderCard();

fireEvent.click(screen.getByText('Approve'));

expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_decide',
params: { request_id: 'req-approval-1', decision: 'approve_once' },
});
await waitFor(() => {
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeUndefined();
});
});

it('Deny routes deny to approval_decide', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({});
const { store } = renderCard();

fireEvent.click(screen.getByText('Deny'));

expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_decide',
params: { request_id: 'req-approval-1', decision: 'deny' },
});
await waitFor(() => {
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeUndefined();
});
});

it('keeps the prompt and shows an error when the decide RPC fails', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('gate not installed'));
const { store } = renderCard();

fireEvent.click(screen.getByText('Approve'));

await waitFor(() => {
// Raw RPC error text ('gate not installed') is no longer surfaced to the
// user — it's kept in a namespaced debug log; the localized fallback shows.
expect(screen.getByText(/Could not record your decision/)).toBeInTheDocument();
});
// Decision failed → approval stays parked, buttons remain actionable.
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toEqual(approval);
expect(screen.getByText('Approve')).toBeInTheDocument();
});

it('falls back to the generic prompt when the approval has no message', () => {
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
const noMessage: PendingApproval = { ...approval, message: '' };
store.dispatch(setPendingApprovalForThread({ threadId: THREAD, approval: noMessage }));
render(
<Provider store={store}>
<ApprovalRequestCard threadId={THREAD} approval={noMessage} />
</Provider>
);
expect(
screen.getByText('The agent wants to run an action that needs your approval.')
).toBeInTheDocument();
});
});
16 changes: 16 additions & 0 deletions app/src/components/settings/SettingsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ const SettingsHome = () => {
),
onClick: () => navigateToSettings('appearance'),
},
{
id: 'agent-access',
title: t('settings.agentAccess.title'),
description: t('settings.agentAccess.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
),
onClick: () => navigateToSettings('agent-access'),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
id: 'mascot',
title: t('settings.mascot.menuTitle'),
Expand Down
Loading
Loading