Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ and finding each fails at least one. Check every change against all three.
something they have to turn off. Under the ALLOW / ASK / BLOCK model (product
plan, Phase 2) this means every write tool starts BLOCK on a new install. A
starting preset may be offered; it is never pre-applied. Decided 2026-09-27.
*Built in #364 (2026-10-14):* a new app starts at Read only, which blocks
every write; each tool then has a site-wide mode, Allow, Ask first or Off
(`Saddle_Tool_Modes`), which can only narrow the app's role. The
recommended set is one click in Settings and only tightens. A changing
tool that did not exist on the site's first run (a later update, a plugin
activated later) arrives as Ask first (R3).

3. **No destructive action without a two-step confirm.** Any ability that mutates
more than one row, or deletes/overwrites without recovery, goes through
Expand Down Expand Up @@ -219,6 +225,14 @@ bound to action, target and `bind`). Two rules that are easy to get wrong:
a destructive call must pass `bind` covering every argument the preview showed,
not just the target; and a refusal must name the gate that caused it, because
`denial_reason()` is the only thing standing between an agent and a retry loop.
`Saddle_Tool_Modes` (#364) adds Ask first: like rehearsal, it wraps every
changing tool's execute callback at registration (priority 5, so rehearsal at
10 wraps it), and until the owner approves under Needs your OK the tool's own
code never runs. A tool's own preview step inside the approved run is
answered by that approval (`Saddle_Approval::run_approved()`), and
`consume_token()` refuses a confirm for an Ask-first tool until the owner
said yes, without spending the token. A token reaches a tool's code only if
it is a live token from that same tool's own preview.

**3. Tools — what the agent is offered.** Every ability declares `accessLevel`,
`destructive` and a tier via `saddle_ability_meta()` — free, Pro and the wrapped
Expand Down Expand Up @@ -494,6 +508,9 @@ change. `Tested up to:` moves only when Fahim has verified against a newer WP.
only path that works while the OAuth toggle is off — which is the default.
- Don't turn OAuth on by default, and don't start a new app above `read` or let a
tool change a role. All three are load-bearing for non-negotiable #2.
- Don't let a tool change a mode, don't let the recommended set loosen
anything, and don't let a new changing tool arrive at Allow. Ask first is
the owner's word in wp-admin; never accept a chat "yes" in its place.
- Don't wire up `Saddle_Ecosystem`.
- Don't put licensing or upsell code in free, and never let a free tool mention
Pro or leave a visible gap for a Pro tool (wp.org guidelines 5 and 11). Builder
Expand Down
2 changes: 1 addition & 1 deletion admin/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '2f4f15c59a7d84460eae');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => 'c87e0341ed14c197e13a');
13 changes: 8 additions & 5 deletions admin/build/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion admin/build/style-index-rtl.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion admin/build/style-index.css

Large diffs are not rendered by default.

24 changes: 19 additions & 5 deletions admin/src/components/NeedsYourOk.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,25 @@ export default function NeedsYourOk( { onChange } ) {
<Row
key={ item.id }
title={ requestTitle( item ) }
description={ sprintf(
/* translators: %s: how long ago, e.g. "3 min ago". */
__( 'Asked %s · waits until you decide', 'saddle' ),
askedAgo( item.created_at )
) }
description={
item.waits
? sprintf(
/* translators: %s: how long ago, e.g. "3 min ago". */
__(
'Asked %s · set to ask you first, so only you can approve it',
'saddle'
),
askedAgo( item.created_at )
)
: sprintf(
/* translators: %s: how long ago, e.g. "3 min ago". */
__(
'Asked %s · waits until you decide',
'saddle'
),
askedAgo( item.created_at )
)
}
actions={
<>
<Button
Expand Down
178 changes: 151 additions & 27 deletions admin/src/components/SettingsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* - Safety: four switches that save the moment they are flipped. A switch
* moves at once, says "Saved." when the server agrees, and moves back if it
* doesn't (R9, P16).
* - Advanced: rarely used things, each collapsed — single tools, sign-in for
* - Advanced: rarely used things, each collapsed — what each tool may do
* (Allow, Ask first or Off, #364), sign-in for
* apps, memory limits, recent changes, the connection check.
* `&section=signin` (the connect drawer's "Turn it on") opens Sign-in for
* apps and scrolls to it. The connection check runs only once opened: it
Expand All @@ -21,18 +22,21 @@ import {
useCallback,
} from '@wordpress/element';
import {
Badge,
Button,
Collapsible,
HelpTip,
Row,
RowList,
Select,
Skeleton,
Switch,
Tooltip,
toast,
} from '@plugpress/ui';
import { __, sprintf } from '@wordpress/i18n';
import { __, _n, sprintf } from '@wordpress/i18n';
import { api } from '../api';
import { disabledSet, flipTool, groupTools } from '../tools-list';
import { groupTools, modeMap, modesFor, withMode } from '../tools-list';
import { runnableTools, safetyPrefs } from '../settings-logic';
import SectionHeader from './SectionHeader';
import SettingsForm from './SettingsForm';
Expand Down Expand Up @@ -188,27 +192,38 @@ function Safety( { onRehearsalChanged } ) {
);
}

// The three modes, as the menu names them (#364).
const MODE_LABELS = {
allow: __( 'Allow', 'saddle' ),
ask: __( 'Ask first', 'saddle' ),
off: __( 'Off', 'saddle' ),
};

/**
* Turn off single tools: a search, then every tool that can run on this site
* as a row with a switch, grouped by area. A switch moves at once and saves
* (the whole switched-off set goes to `POST /abilities`); off stays off for
* every app. A tool for a plugin that isn't active is left out of the list,
* and its own on or off is kept.
* What each tool may do: a search, a one-click recommended set, then every
* tool that can run on this site as a row with Allow, Ask first or Off,
* grouped by area (#364). A choice shows at once and saves through
* `POST /tool-modes`; it holds for every app, under each app's own role. A
* tool for a plugin that isn't active is left out of the list, and its mode
* is kept.
*
* @param {Object} props
* @param {Array} props.caps Every tool, from `GET /capabilities`.
* @param {Array} props.listed The tools to list (runnableTools()).
* @param {Function} props.onChanged Reloads the tools.
* @param {Function} props.onApplied Called after the recommended set, which
* also turns on a Safety switch.
*/
function ToolSwitches( { caps, listed, onChanged } ) {
function ToolModes( { caps, listed, onChanged, onApplied } ) {
const [ query, setQuery ] = useState( '' );
const [ off, setOff ] = useState( () => disabledSet( caps ) );
const [ modes, setModes ] = useState( () => modeMap( caps ) );
const [ applying, setApplying ] = useState( false );
// Saves on their way. The tools reload only when the last one is back,
// so a reload never shows a switch from before a newer flip.
// so a reload never shows a mode from before a newer choice.
const pending = useRef( 0 );

// The server's answer wins whenever the tools reload.
useEffect( () => setOff( disabledSet( caps ) ), [ caps ] );
useEffect( () => setModes( modeMap( caps ) ), [ caps ] );

const groups = useMemo(
() => groupTools( listed, query, __( 'Other', 'saddle' ) ),
Expand All @@ -222,27 +237,92 @@ function ToolSwitches( { caps, listed, onChanged } ) {
}
}, [ onChanged ] );

const flip = ( short ) => {
const next = flipTool( off, short );
setOff( next );
const choose = ( short, mode ) => {
const before = modes.get( short );
if ( before === mode ) {
return;
}
setModes( ( now ) => withMode( now, short, mode ) );
pending.current += 1;
api( 'abilities', {
api( 'tool-modes', {
method: 'POST',
data: { disabled: [ ...next ] },
data: { tool: short, mode },
} )
.then( () => toast.success( __( 'Saved.', 'saddle' ) ) )
.catch( ( e ) => {
// Put back this one switch; any other flip stands.
setOff( ( now ) => flipTool( now, short ) );
// Put back this one tool; any other choice stands.
setModes( ( now ) => withMode( now, short, before ) );
toast.error(
e?.message || __( 'Could not save that change.', 'saddle' )
);
} )
.finally( settle );
};

const applyRecommended = () => {
setApplying( true );
api( 'tool-modes/recommended', { method: 'POST' } )
.then( ( res ) => {
const moved = ( res && res.moved ) || [];
toast.success(
moved.length
? sprintf(
/* translators: %d: number of tools. */
_n(
'%d tool now asks you first. Publishing needs your OK.',
'%d tools now ask you first. Publishing needs your OK.',
moved.length,
'saddle'
),
moved.length
)
: __(
'Already set. Publishing needs your OK.',
'saddle'
)
);
if ( onApplied ) {
onApplied();
}
if ( onChanged ) {
onChanged();
}
} )
.catch( ( e ) =>
toast.error(
e?.message || __( 'Could not save that change.', 'saddle' )
)
)
.finally( () => setApplying( false ) );
};

return (
<div className="saddle-tools">
<p className="saddle-tools__intro">
{ __(
'Ask first: the app shows you the change and it waits under Needs your OK on the Dashboard. It runs only after you approve it there.',
'saddle'
) }
</p>
<RowList>
<Row
title={ __( 'Recommended set', 'saddle' ) }
description={ __(
'Ask first for plugins, themes, updates, settings and anything that deletes. Publishing needs your OK. Content edits stay allowed. Nothing you turned off is turned on.',
'saddle'
) }
actions={
<Button
variant="secondary"
size="sm"
onClick={ applyRecommended }
disabled={ applying }
>
{ __( 'Use it', 'saddle' ) }
</Button>
}
/>
</RowList>
<input
type="search"
className="saddle-tools__search"
Expand All @@ -269,11 +349,49 @@ function ToolSwitches( { caps, listed, onChanged } ) {
</Tooltip>
}
actions={
<Switch
checked={ ! off.has( tool.short ) }
onChange={ () => flip( tool.short ) }
aria-label={ tool.label }
/>
<>
{ tool.new &&
'ask' ===
modes.get( tool.short ) && (
<Tooltip
content={ __(
'Added since you last chose. It asks you first until you pick.',
'saddle'
) }
>
<span>
<Badge>
{ __(
'New',
'saddle'
) }
</Badge>
</span>
</Tooltip>
) }
<Select
className="saddle-tools__mode"
size="sm"
options={ modesFor( tool ).map(
( m ) => ( {
value: m,
label: MODE_LABELS[ m ],
} )
) }
value={ modes.get( tool.short ) }
onChange={ ( mode ) =>
choose( tool.short, mode )
}
aria-label={ sprintf(
/* translators: %s: tool name. */
__(
'What %s may do',
'saddle'
),
tool.label
) }
/>
</>
}
/>
) ) }
Expand Down Expand Up @@ -322,6 +440,8 @@ export default function SettingsPage( {
const signInRef = useRef( null );
// The connection check mounts when first opened and stays after.
const [ checkOpened, setCheckOpened ] = useState( false );
// The recommended set turns on a Safety switch; remounting Safety reloads it.
const [ safetyKey, setSafetyKey ] = useState( 0 );
const tools = useMemo( () => runnableTools( caps ), [ caps ] );

// Bring Sign-in for apps into view. Safety above loads its own data and
Expand All @@ -343,22 +463,26 @@ export default function SettingsPage( {

return (
<>
<Safety onRehearsalChanged={ onRehearsalChanged } />
<Safety
key={ safetyKey }
onRehearsalChanged={ onRehearsalChanged }
/>

<section className="saddle-section">
<SectionHeader title={ __( 'Advanced', 'saddle' ) } />
<div className="saddle-adv">
<Collapsible
trigger={ sprintf(
/* translators: %d: number of tools. */
__( 'Turn off single tools (%d)', 'saddle' ),
__( 'What each tool may do (%d)', 'saddle' ),
tools.length
) }
>
<ToolSwitches
<ToolModes
caps={ caps }
listed={ tools }
onChanged={ loadCaps }
onApplied={ () => setSafetyKey( ( k ) => k + 1 ) }
/>
</Collapsible>
<Collapsible
Expand Down
12 changes: 12 additions & 0 deletions admin/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2548,6 +2548,18 @@ body.saddle-admin-page {
font-size: var( --s-text );
color: var(--s-fg-subtle);
}

&__intro {
margin: 0;
padding-inline: var(--pp-space-4);
font-size: var( --s-text );
color: var(--s-fg-subtle);
}

// Allow / Ask first / Off: one width, so the column lines up.
&__mode {
min-width: 112px;
}
}

/* Client traffic — the MCP request record. Deliberately plain: this is a
Expand Down
Loading
Loading