diff --git a/client/src/components/cos/AppProviderPin.jsx b/client/src/components/cos/AppProviderPin.jsx index 8b0c8dc3e4..5e36a1863e 100644 --- a/client/src/components/cos/AppProviderPin.jsx +++ b/client/src/components/cos/AppProviderPin.jsx @@ -41,7 +41,8 @@ export default function AppProviderPin({ disabled = false, loading = false, compact = false, - layout = 'row' + layout = 'row', + selectionPolicy }) { const selectedProviderId = providerId || ''; const selectedModel = model || ''; @@ -72,6 +73,7 @@ export default function AppProviderPin({ loading={loading} compact={compact} layout={layout} + selectionPolicy={selectionPolicy} /> ); } diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js index 14cb3a479d..c178e95cdd 100644 --- a/client/src/components/cos/constants.js +++ b/client/src/components/cos/constants.js @@ -351,14 +351,10 @@ export { sanitizeReviewerModelInput } from '../../lib/reviewerPins'; -// pr-watcher author gate (taskMetadata.prAuthorFilter). Mirrors -// PR_AUTHOR_FILTERS in server/lib/validation.js. 'self' = PRs opened by the -// gh-authenticated operator (or their automation); 'others' = external -// contributors; 'any' = react to every opened PR. +// pr-watcher owns trusted remediation. Legacy filter values remain accepted +// server-side for compatibility; every dispatch enforces collaborator trust. export const PR_AUTHOR_FILTER_OPTIONS = [ - { value: 'any', label: 'Any author', description: 'React to every PR opened on the default branch' }, - { value: 'self', label: 'Opened by me', description: 'Only PRs opened by the gh-authenticated user (or their automation)' }, - { value: 'others', label: 'Opened by others', description: 'Only PRs opened by someone other than the gh-authenticated user' } + { value: 'trusted', label: 'Owner and write collaborators', description: 'Verified repository collaborators and the signed-in operator; external PRs use PR Reviewer' } ]; // claim-issue author gate (taskMetadata.issueAuthorFilter). Mirrors diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx index 9963c95919..19d21379cd 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx @@ -215,6 +215,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
Leave as default to use the currently active provider
+{toolFree + ? <>Uses a text API with no tools. Default follows Abuse Guard source settings; a saved CLI provider must be cleared or replaced.> + : 'Leave as default to use the currently active provider'}
- {PR_AUTHOR_FILTER_OPTIONS.find(o => o.value === (config.taskMetadata?.prAuthorFilter || 'any'))?.description}
+ {PR_AUTHOR_FILTER_OPTIONS.find(o => o.value === 'trusted')?.description}
{' '}Edit the prompt below to control what the agent does for each opened PR (it can use {'{prData}'}, {'{repoFullName}'}, {'{defaultBranch}'}).
Run final code review and actions
- When enabled, a sandbox-capable reviewer applies only the screened patch, runs local tests, and returns a structured review for the deterministic GitHub coordinator. It is nested here, not a separate scheduled task. + When enabled, a tool-free reviewer analyzes screened PR content and returns a structured static review. The deterministic GitHub coordinator validates any resulting actions. Contributor code is never executed. This stage is nested here, not a separate scheduled task.
Deterministic hidden-content screen
Server-side checks on each external PR's complete title, description, and diff for content a human reviewer would miss — invisible or direction-control Unicode, comments GitHub never renders that address a model — and for obvious model-directed harm: instruction overrides, decode-and-follow or download-and-run instructions, credential exfiltration, and attempts to steer the review verdict. No model, tools, repository checkout, or GitHub credentials are involved.
- The pinned Llama Prompt Guard 2 classifier runs as an optional second layer only when it is installed on{' '} + The pinned Llama Prompt Guard 2 classifier is required by default. Install and configure it on{' '} Models → LLMs → Abuse Guard.
{localModelsLoading ? 'Loading installed local model capability reports…' - : NO_TOOL_STAGE_NOTE} + : `${role === 'actions' ? 'Tool-free review. Returns a structured static review for server-validated actions; it cannot run contributor code or tests.' : role === 'eligibility' ? 'Tool-free stage. Returns only a binary allowlist; rejected content never reaches the final review.' : 'Tool-free stage.'} ${NO_TOOL_STAGE_NOTE}`}
)} {isActionsStage && eligibleProviders?.length > 0 && ( @@ -273,7 +258,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi{needsSecurityModelPolicy - ? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the tool-free Eligibility Gate, and only eligible PRs reach the optional sandboxed final review. Stages are nested, not independently scheduled.' + ? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the tool-free Eligibility Gate, and only eligible PRs reach the optional tool-free final review. The server validates resulting GitHub actions. Stages are nested, not independently scheduled.' : 'Each stage runs as a separate agent inside this pipeline; stages are not scheduled independently.'} {' Configure a different provider, model, and thinking effort per stage.'}
diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx index fbadc00fe2..27bb8913d1 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx @@ -104,17 +104,19 @@ function renderStages(stages = STAGES, onUpdate = vi.fn().mockResolvedValue(unde } describe('PipelineStageConfig — pr-reviewer', () => { - it('uses shared capability policies for the gate and sandbox-capable action providers', () => { + it('requires tool-free providers for both PR stages and marks an unsafe saved pin unavailable', () => { renderStages(); const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[0].options].map((option) => option.value)).toEqual(['', 'claude-ollama']); - expect([...providerSelects[1].options].map((option) => option.value)).toEqual(['', 'codex-cli', 'antigravity-cli']); + expect([...providerSelects[1].options].map((option) => option.value)).toEqual(['', 'claude-ollama', 'codex-cli']); + expect(providerSelects[1].querySelector('option[value="codex-cli"]')).toBeDisabled(); const modelSelects = screen.getAllByLabelText('Model'); expect([...modelSelects[0].options].map((option) => option.value)).toEqual(['', 'safe-model']); expect([...modelSelects[1].options].map((option) => option.value)).toEqual(['', 'gpt-5.6']); - expect(screen.getByText(/maintained OS sandbox/i)).toBeInTheDocument(); + expect(screen.getByText(/^Tool-free review\./)).toBeInTheDocument(); + expect(screen.queryByText(/applies only the screened patch/)).not.toBeInTheDocument(); }); it('removes the optional actions stage without changing the mandatory gate', async () => { @@ -139,7 +141,7 @@ describe('PipelineStageConfig — pr-reviewer', () => { expect.objectContaining({ role: 'actions', promptKey: 'pr-reviewer-review', - executionProfile: 'public-review-actions', + executionProfile: 'public-review-gate', discardWorktree: true, noCodeOutput: true, }), @@ -184,7 +186,7 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { // A non-local provider's own catalog is selectable — the installed-local // model list only applies where PortOS can probe capabilities. expect(screen.getByText(/^Tool-free stage\./)).toBeInTheDocument(); - expect(screen.getByText(/^Sandboxed stage\./)).toBeInTheDocument(); + expect(screen.getByText(/^Tool-free review\./)).toBeInTheDocument(); }); // The bug behind #5906's blocked run: the CLI records were disabled and the @@ -202,43 +204,32 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui']); // The dropdown IS the eligible list; the note must not re-name providers, // least of all the disabled ones. - const note = screen.getByText(/^Sandboxed stage\./); + const note = screen.getByText(/^Tool-free review\./); expect(note.textContent).not.toContain('Grok Build CLI'); expect(note.textContent).not.toContain('Codex CLI'); }); it('warns instead of silently offering nothing when a stage has no eligible provider', () => { renderWith([ - { id: 'claude-ollama', name: 'Local Claude', type: 'cli', command: 'claude', endpoint: 'http://127.0.0.1:11434', models: ['safe-model'], publicReviewPostures: ['no-tool'] }, + { id: 'actions-only', name: 'Actions Only', type: 'cli', command: 'example', models: ['example-model'], publicReviewPostures: ['sandboxed-actions'] }, ]); - expect(screen.getByText(/No enabled AI provider on this install can enforce the sandboxed-actions posture/)).toBeInTheDocument(); + expect(screen.getAllByText(/No enabled AI provider on this install can enforce the tool-free posture/)).toHaveLength(2); }); - // Stage 3 offers every enabled binary provider the server publishes as - // runnable, and the note separates the vendor-sandboxed ones from those the - // disposable worktree alone isolates. - it('offers a worktree-only provider for the actions stage and says which providers are OS-sandboxed', () => { + it('excludes worktree-only providers from both PR stages despite a legacy action profile', () => { renderWith([ { id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'], publicReviewEnforcedPostures: ['no-tool', 'sandboxed-actions'] }, { id: 'opencode-tui', name: 'OpenCode TUI', type: 'tui', command: 'opencode', models: ['x'], publicReviewPostures: ['sandboxed-actions'], publicReviewEnforcedPostures: [] }, ]); const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[0].options].map((o) => o.value)).toEqual(['', 'codex-tui']); - expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui', 'opencode-tui']); - const note = screen.getByText(/^Sandboxed stage\./); - // The eligible set is the dropdown's job; the note only separates the - // vendor-sandboxed providers from the worktree-only ones. - expect(note.textContent).not.toContain('Eligible on this install'); - expect(note.textContent).toContain("OS-sandboxed by the vendor's own recipe: Codex TUI."); - expect(note.textContent).toContain('isolated by the disposable worktree only: OpenCode TUI.'); + expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui']); + expect(screen.queryByText(/^Sandboxed stage\./)).not.toBeInTheDocument(); }); - // A local runtime's daemon is the authority on what it serves; the provider - // record's `models` array is a cached snapshot. The tool-free gate already - // read the daemon, but the sandboxed actions stage read the snapshot — so a - // stage on a local provider could only be pinned to models that had since - // been removed, and never to one just pulled. - it('offers the installed local models for a local-backed ACTIONS stage', () => { + // A saved model that now advertises tools must remain visible as unavailable, + // while newly installed models without tools are offered immediately. + it('requires installed no-tool local models for the final PR review', () => { const localProvider = { id: 'opencode-ollama-tui', name: 'OpenCode Ollama TUI', @@ -267,16 +258,15 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { const modelSelects = screen.getAllByLabelText('Model'); // The daemon's installed models, NOT the record's `stale-cached-model`. - expect([...modelSelects[1].options].map((o) => o.value)).toEqual(['', 'safe-model', 'tool-model']); + expect([...modelSelects[1].options].map((o) => o.value)).toEqual(['', 'tool-model', 'safe-model']); + expect(modelSelects[1].querySelector('option[value="tool-model"]')).toBeDisabled(); + expect(modelSelects[1].querySelector('option[value="safe-model"]')).not.toBeDisabled(); }); }); // `useLocalModels` reports "not fetched yet" and "the daemon listed nothing" // identically, as `[]` — so an empty list is not evidence the daemon serves no -// models. The actions stage has no capability gate, so it must fall back to the -// record's catalog rather than render an empty picker that also drops the -// stage's own saved pin. The tool-free gate deliberately does NOT: a model with -// no probeable capability report is not selectable there at all. +// models. Neither PR stage may offer cached models without capability reports. describe('PipelineStageConfig — local daemon unreachable', () => { const LOCAL = { id: 'opencode-ollama-tui', @@ -288,7 +278,7 @@ describe('PipelineStageConfig — local daemon unreachable', () => { publicReviewEnforcedPostures: ['no-tool'], }; - it('falls back to the record catalog for the actions stage, but not for the gate', () => { + it('rejects unverified cached models in both PR stages while keeping saved pins visibly unavailable', () => { localModels = { ollama: [], lmstudio: [], capabilitiesByBackend: {}, loading: false }; render(- Configure separate AI providers for email triage (classification) and reply generation. + Email analysis requires a local text API provider. Configure screening and dedicated source overrides in Abuse Guard; those overrides take priority over the selections below.
Optional second layer · managed classifier
+Required by default · local classifier
- The PR reviewer's Stage 1 always runs deterministic checks for content hidden from a human reader (invisible or direction-control Unicode, unrendered comments addressed to a model) and obvious model-directed harm. When installed, Llama Prompt Guard 2 86M additionally classifies each complete external PR before it reaches a reasoning agent. It is a pinned local classifier with no chat, tools, MCP, or repository access; flagged or inconclusive content is withheld. + External issues, pull requests, and connected message analysis use layered screening: deterministic checks and a local classifier, isolated analysis without tools, then server-validated actions. Missing, failed, or inconclusive required screening blocks analysis. A passing scan never grants trust, proves an attachment safe, or authorizes access to private records. +
++ Llama Prompt Guard 2 86M is recommended for its multilingual detection. Meta also offers a smaller 22M model with lower multilingual accuracy; this installer supports the pinned 86M model. It scans overlapping 512-token windows locally on CPU. No chat model, GPU, or cloud account is required for screening. Classifiers can miss adaptive attacks and can flag legitimate security examples. +
++ Setup downloads Python packages and model weights only when you select Install. Status refreshes make no model calls. Accept the model terms, add a read token, and install Python on this machine if needed. Private message analysis also requires a local API provider; configure it below after installing a text model in LLMs.
{stage.description}
+ {stage.id === 'python' && !stage.ready && ( + Install Python, then refresh status + )} {current && progressMsg && ({progressMsg}
)} @@ -182,6 +196,9 @@ export default function ModelAbuseGuardPanel() {A partial or failed installation blocks screening, including sources with an optional classifier. Repair the setup before retrying those tasks.
} +Shared defaults apply to every source. Messages adds defaults for email, iMessage, and Signal; each channel can override them. Private message analysis stays on this machine. Screening never turns external text into instructions or gives the analysis model tools. GitHub review stages retain their separate schedule settings.
+ {error &&{error}
} + {!config ? !error &&Loading policies…
: ( + + )} +