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
79 changes: 79 additions & 0 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3259,6 +3259,85 @@ def api_hub_override_costs():
"project": project_path})


@app.route("/api/hub/overrides/policy", methods=["GET"])
def api_hub_override_policy_get():
"""The effective `override` section for one project.

WHY THE HUB NEEDS THIS. The hub could already LIST override requests and
DECIDE them, but not say whether requests may exist at all — that lives in
`override.enabled`, which was reachable only from the phone API or by hand-
editing `.c3/config.json`. So the hub could answer a question the agent was
never allowed to ask, and the only way to change that was a text editor.

Registered BEFORE the POST-only ``<request_id>`` route for the same reason
``costs`` is: a GET on ``policy`` must never fall through to a 405 there.
"""
from services import override_policy as opol
raw = (request.args.get("path") or "").strip()
if not raw:
return jsonify({"error": "path is required"}), 400
try:
project_path = str(_resolve_project_path(raw))
except ValueError as e:
return jsonify({"error": str(e)}), 404
policy = opol.resolve(project_path)
return jsonify({
"path": project_path,
"project_name": Path(project_path).name,
"policy": policy.as_dict(),
"layers": list(opol.LAYER_KEYS),
"typed_confirm_layers": sorted(opol.TYPED_CONFIRM_LAYERS),
"hard_max_ttl_s": opol.HARD_MAX_TTL_S,
# Verbatim from the spec, and the one thing a toggle screen must not
# let the reader forget: policy is a floor, not a promise.
"coverage_note": (
"Project and global policy merge by tightening only: a layer is "
"escalatable iff both scopes allow it, and the numeric limits take "
"the smaller value."
),
})


@app.route("/api/hub/overrides/policy", methods=["POST"])
def api_hub_override_policy_set():
"""Edit one project's `override` section from the hub.

Widening — enabling the feature or turning a layer on — needs
``confirm: "widen"``. Tightening never does: making the guard stricter is
always allowed to be one click.

Validation, widening detection and the write are
``services.override_policy``'s, shared verbatim with the phone route. Two
implementations of "what counts as widening" would be two chances for one
surface to allow what the other refuses, and this is the check that decides
whether an action needs a typed confirmation.

``wake`` is accepted here and refused on the phone: it names a command this
machine runs, and the hub is a desktop surface on this box.
"""
from services import override_policy as opol
data = request.get_json(silent=True) or {}
raw = (data.get("path") or "").strip()
if not raw:
return jsonify({"error": "path is required"}), 400
try:
project_path = str(_resolve_project_path(raw))
except ValueError as e:
return jsonify({"error": str(e)}), 404
try:
written, widens = opol.apply_section(
project_path, data.get("override"),
confirmed=(data.get("confirm") == "widen"),
allow_wake=True)
except opol.PolicyEditError as exc:
return jsonify(exc.payload), exc.status
except (OSError, ValueError) as exc:
return jsonify({"error": f"{type(exc).__name__}: {exc}"}), 500
return jsonify({"path": project_path, "written": written,
"policy": opol.resolve(project_path).as_dict(),
"widened": widens})


@app.route("/api/hub/overrides/<request_id>", methods=["POST"])
def api_hub_override_decide(request_id):
"""Approve (minting a grant) or deny one request. decided_by='desktop'.
Expand Down
183 changes: 183 additions & 0 deletions cli/hub_ui/components/hub_access.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,187 @@ function AccGrantsPanel({ projects }) {
}

// ── The tab ────────────────────────────────────────────────────────────────
// ─── Override policy (the switch, not the requests) ───────────────────────
//
// WHY THIS PANEL EXISTS. This screen could already LIST override requests and
// DECIDE them — but not say whether requests may exist at all. That lives in
// `override.enabled`, and it was reachable only from the phone API or by hand-
// editing .c3/config.json. So the hub answered a question the agent was never
// allowed to ask, and the way to change that was a text editor.
//
// THE ONE RULE HERE: widening is never silent. Enabling the feature, or
// turning a layer on, loosens what a single approval can allow. The server
// refuses those without confirm:"widen"; this panel asks in plain words BEFORE
// sending, rather than surfacing a 400 afterwards. Tightening goes straight
// through — making the guard stricter should always be one click.

// Plain-language layer names. The raw keys are accurate and mean nothing to
// someone deciding whether to tick a box.
const ACC_LAYER_NAMES = {
discipline: 'Discipline holds (native-tool nudges)',
access_readonly: 'Read-only paths — e.g. .git/**, .c3/**',
access_deny: 'Denied paths',
access_builtin: 'Built-in guards',
access_confirm: 'Confirm holds — agent-config files',
mask: 'Masked paths',
shell_warn: 'Shell warnings',
};

function accLayerName(key) { return ACC_LAYER_NAMES[key] || key; }

function AccPolicyPanel({ projects }) {
const [path, setPath] = useState('');
const [data, setData] = useState(null);
const [draft, setDraft] = useState(null); // {enabled, layers}
const [err, setErr] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);

const load = useCallback(async (p) => {
if (!p) { setData(null); setDraft(null); setErr(''); setMsg(''); return; }
try {
const d = await api.get(`/api/hub/overrides/policy?path=${encodeURIComponent(p)}`);
setData(d);
setDraft({
enabled: !!(d.policy || {}).enabled,
layers: { ...((d.policy || {}).layers || {}) },
});
setErr(''); setMsg('');
} catch (e) { setData(null); setDraft(null); setErr(apiErr(e)); }
}, []);

// Mirrors the server's own rule so the question is asked before the request,
// not after the refusal.
const widenings = () => {
if (!data || !draft) return [];
const cur = data.policy || {};
const out = [];
if (draft.enabled && !cur.enabled) out.push('enable override requests');
Object.entries(draft.layers || {}).forEach(([k, want]) => {
if (want && !(cur.layers || {})[k]) out.push(`allow asking about: ${accLayerName(k)}`);
});
return out;
};

const save = async () => {
if (!draft || !path) return;
const widens = widenings();
if (widens.length) {
const ok = window.confirm(
`This LOOSENS the guard:\n\n • ${widens.join('\n • ')}\n\n` +
'A yes still only lets the agent ASK — every request needs your ' +
'approval, and a grant is single-use and path-exact.\n\nApply?');
if (!ok) { setMsg('Unchanged.'); return; }
}
setBusy(true);
try {
const d = await api.post('/api/hub/overrides/policy', {
path,
override: { enabled: draft.enabled, layers: draft.layers },
confirm: widens.length ? 'widen' : undefined,
});
setData(d);
setDraft({
enabled: !!(d.policy || {}).enabled,
layers: { ...((d.policy || {}).layers || {}) },
});
setErr('');
setMsg((d.widened && d.widened.length)
? `Saved — widened: ${d.widened.join(', ')}`
: 'Saved.');
} catch (e) { setErr(apiErr(e)); }
setBusy(false);
};

const typed = new Set((data && data.typed_confirm_layers) || []);
const dirty = !!(data && draft) && (
draft.enabled !== !!(data.policy || {}).enabled ||
Object.entries(draft.layers || {}).some(
([k, v]) => !!v !== !!((data.policy || {}).layers || {})[k]));

return (
<div style={{
background: T.surface, border: `1px solid ${T.border}`, borderRadius: 10,
padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: T.text }}>May agents ask?</div>
<select value={path}
onChange={e => { setPath(e.target.value); load(e.target.value); }}
style={{
background: T.surfaceAlt, color: T.text, border: `1px solid ${T.border}`,
borderRadius: 6, fontSize: 12, padding: '4px 8px', maxWidth: 340,
}}>
<option value="">— pick a project —</option>
{(projects || []).map(p => (
<option key={p.path} value={p.path}>{p.name || p.path}</option>
))}
</select>
<span style={{ fontSize: 10.5, color: T.textDim }}>
off by default, per project
</span>
</div>

{err && <div style={{ marginTop: 8, fontSize: 11.5, color: T.error }}>{err}</div>}

{data && draft && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
<input type="checkbox" checked={draft.enabled}
onChange={e => setDraft({ ...draft, enabled: e.target.checked })} />
<span style={{ color: T.text, fontWeight: 600 }}>
Allow override requests for this project
</span>
</label>

<div>
<div style={{
fontSize: 10.5, fontWeight: 700, letterSpacing: 1,
textTransform: 'uppercase', color: T.textDim, marginBottom: 4,
}}>Escalatable layers</div>
{(data.layers || []).map(k => (
<label key={k} style={{
display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer',
fontSize: 11.5, color: T.textMuted, padding: '2px 0',
}}>
<input type="checkbox" checked={!!draft.layers[k]}
onChange={e => setDraft({
...draft, layers: { ...draft.layers, [k]: e.target.checked },
})} />
<span>{accLayerName(k)}</span>
{typed.has(k) && (
<span style={{ fontSize: 10, color: T.textDim }}>
— approving one makes you retype the rule
</span>
)}
</label>
))}
</div>

<div style={{ fontSize: 10.5, color: T.textDim, lineHeight: 1.5 }}>
{data.coverage_note}
<br />
Never escalatable at any setting: the credential vault,
{' '}<span className="mono">.c3/secrets.enc</span>,
{' '}<span className="mono">.c3/cred_state.json</span>, the dispatcher
fail-closed deny, and the catastrophic shell blocks.
</div>

<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button onClick={save} disabled={busy || !dirty} style={{
borderRadius: 6, fontSize: 12, padding: '5px 12px', cursor: dirty ? 'pointer' : 'default',
border: `1px solid ${dirty ? T.accent : T.border}`,
background: dirty ? T.accentDim : 'transparent',
color: dirty ? T.accent : T.textDim, fontWeight: dirty ? 700 : 400,
}}>{busy ? 'Saving…' : 'Save'}</button>
{msg && <span style={{ fontSize: 11.5, color: T.textMuted }}>{msg}</span>}
</div>
</div>
)}
</div>
);
}

function HubAccess({ projects }) {
const [rows, setRows] = useState([]);
const [loaded, setLoaded] = useState(false);
Expand Down Expand Up @@ -559,6 +740,8 @@ function HubAccess({ projects }) {

<AccCostStrip onOpenRule={openRule} />

<AccPolicyPanel projects={projects} />

{!loaded ? (
<div style={{ fontSize: 12, color: T.textDim }}>Loading…</div>
) : rows.length === 0 ? (
Expand Down
34 changes: 29 additions & 5 deletions oracle/services/mobile_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2332,7 +2332,7 @@ def mobile_overrides_policy_set():
section = data.get("override")
if not isinstance(section, dict):
return jsonify({"error": "body needs an 'override' object"}), 400
if _WAKE_KEY in section:
if _WAKE_KEY in section: # noqa: SIM102 — kept for the phone-specific copy
# `wake` is an argv this machine will execute. Everything else on this
# route widens what a tap can approve; this one would decide what runs
# when it does. A bearer token from a phone is authentication, not
Expand Down Expand Up @@ -2387,7 +2387,23 @@ def mobile_overrides_policy_set():


def _override_widenings(current, section: dict) -> list:
"""Which requested changes LOOSEN the policy. Names, for the challenge."""
"""Which requested changes LOOSEN the policy. Names, for the challenge.

DELEGATES to ``services.override_policy.widenings`` since 2.129.2, when the
hub gained its own Security screen. Two copies of "what counts as widening"
is two chances for one surface to quietly allow something the other
refuses — and this is the check that decides whether a tap needs a typed
confirmation. The name is kept because the mobile route tests call it.
"""
from services import override_policy as _opol # noqa: PLC0415

return _opol.widenings(current, section)


def _override_widenings_legacy(current, section: dict) -> list:
"""Retired inline copy, kept only as the reference the extraction came
from; ``tests/test_override_policy_shared.py`` asserts it agrees with the
service so the move can be shown to have preserved behaviour."""
out = []
if section.get("enabled") and not current.enabled:
out.append("enabled")
Expand Down Expand Up @@ -2416,10 +2432,18 @@ def _override_widenings(current, section: dict) -> list:
def _write_override_section(project: Path, section: dict) -> dict:
"""Merge *section* into the project's `.c3/config.json` `override` block.

Project scope only. A global override policy governs every project on the
machine, and the phone has no affordance for reviewing that blast radius —
same call `mobile_enforcement_set` makes about machine-wide discipline.
DELEGATES to ``services.override_policy.write_section`` since 2.129.2 so
the hub's Security screen and the phone write the file the same way — same
merge semantics for `layers`, same atomic publish. The name is kept because
the mobile route tests patch it.
"""
from services import override_policy as _opol # noqa: PLC0415

return _opol.write_section(project, section)


def _write_override_section_legacy(project: Path, section: dict) -> dict:
"""Retired inline copy; see ``_override_widenings_legacy``."""
cfg_file = Path(project) / ".c3" / "config.json"
cfg_file.parent.mkdir(parents=True, exist_ok=True)
cfg = {}
Expand Down
Loading
Loading