fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.
It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.
A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
This commit is contained in:
1 parent
c41c555400
commit
28f20a8aac
8 files changed
+283
-59
No files matched your search
+25
-10
@@ -64,10 +64,19 @@ export const Config = Schema.object({
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
const policy = ctx.facetPolicy
|
||||
/** @type {'ask'|'deny'|'off'} */
|
||||
const mode = policy.mode
|
||||
|
||||
if (mode === 'off') return
|
||||
// Read at the point of use, never captured. `mode` is a volatile field, so a
|
||||
// settings edit updates the policy's reference in place and deliberately does
|
||||
// not re-apply this plugin; a `const mode` here would answer with the value
|
||||
// the session was loaded with, for the life of the session.
|
||||
const modeNow = () => policy.mode
|
||||
|
||||
// `off` is the one part that stays a mount-time decision. Deciding it here
|
||||
// means an `off` deployment contributes no rule, no binding and no tool —
|
||||
// which is the point of the mode. The cost is that turning it *on* later needs
|
||||
// a reload, because a volatile-only edit never re-runs this function. `ask`
|
||||
// and `deny` swap freely in both directions.
|
||||
if (modeNow() === 'off') return
|
||||
|
||||
const sectionOrder = policy.sectionOrder ?? 100
|
||||
const contextOrder = policy.contextOrder ?? 105
|
||||
@@ -129,6 +138,9 @@ export function apply(ctx, config) {
|
||||
const classify = async (exec) => {
|
||||
if (exec.signal?.aborted) return undefined
|
||||
|
||||
const mode = modeNow()
|
||||
if (mode === 'off') return undefined
|
||||
|
||||
// Widening is the one act that always needs the user, whatever the mode:
|
||||
// it is explicit, deliberate, and the only thing standing between a scoped
|
||||
// agent and an unscoped one.
|
||||
@@ -155,11 +167,13 @@ export function apply(ctx, config) {
|
||||
}
|
||||
|
||||
// The rule. `interpolate: false` because every value in it is already
|
||||
// literal; there is nothing here for the harness to substitute.
|
||||
// literal; there is nothing here for the harness to substitute. It carries no
|
||||
// `mode`, because a section is registered once and cannot follow a live edit —
|
||||
// see `facetRuleText`.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'facet-scope',
|
||||
order: Number(sectionOrder),
|
||||
text: facetRuleText({ mode }),
|
||||
text: facetRuleText(),
|
||||
interpolate: false,
|
||||
})
|
||||
|
||||
@@ -173,11 +187,11 @@ export function apply(ctx, config) {
|
||||
text: (context) => {
|
||||
const scope = policy.scopeFor(context?.agent)
|
||||
if (scope === undefined) return ''
|
||||
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode })
|
||||
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode: modeNow() })
|
||||
},
|
||||
})
|
||||
|
||||
if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode))
|
||||
if (config.tool) ctx.tools.register(facetTool(ctx, policy))
|
||||
|
||||
// `next()` first, so an earlier policy plugin keeps its say: this listener
|
||||
// only ever adds a question or a refusal to a call that was otherwise allowed.
|
||||
@@ -206,9 +220,8 @@ function requestedPaths(args) {
|
||||
*
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
|
||||
* @param {object} policy - the `facetPolicy` service.
|
||||
* @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode.
|
||||
*/
|
||||
function facetTool(ctx, policy, mode) {
|
||||
function facetTool(ctx, policy) {
|
||||
return defineTool({
|
||||
name: FACET_TOOL,
|
||||
description:
|
||||
@@ -276,7 +289,9 @@ function facetTool(ctx, policy, mode) {
|
||||
ok: true,
|
||||
root: scope.root,
|
||||
facet: scope.root,
|
||||
mode,
|
||||
// Read here rather than captured at mount, so `status` reports the mode
|
||||
// in force now instead of the one this session started under.
|
||||
mode: String(policy.mode),
|
||||
standing: policy.standing,
|
||||
granted,
|
||||
scope: scope.extras,
|
||||
|
||||
+64
-6
@@ -18,6 +18,38 @@
|
||||
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* The symbol cosmokit uses to mark a volatile config reference.
|
||||
*
|
||||
* Looked up by symbol rather than by importing cosmokit, because the ESM and CJS
|
||||
* copies of that library are distinct module instances and `Symbol.for` is the
|
||||
* only identity they share.
|
||||
*/
|
||||
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
||||
|
||||
/**
|
||||
* Read one configuration field.
|
||||
*
|
||||
* A `volatile()` field is a *reference* in the running config, not a plain
|
||||
* value: the framework resolves the schema through `Schema.resolve`, which wraps
|
||||
* each volatile field in `createVolatile(...)` — `{get(), [write]}`. A
|
||||
* non-volatile field, or a config object a test handed straight to
|
||||
* {@link createFacetPolicy}, is the value itself. Both are accepted, so one code
|
||||
* path serves a real profile and a direct call.
|
||||
*
|
||||
* Every read has to happen at the point of use rather than once at construction.
|
||||
* A settings write commits into these references in place and deliberately does
|
||||
* *not* re-apply the plugin, so anything captured at construction would never
|
||||
* see the change — which is how a plugin ends up with a settings card that does
|
||||
* nothing.
|
||||
*
|
||||
* @param {unknown} value - a field of the resolved config.
|
||||
* @returns {unknown} the current value.
|
||||
*/
|
||||
export function configValue(value) {
|
||||
return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} FacetScope
|
||||
* @property {string} cwd - the session working directory, absolute.
|
||||
@@ -28,6 +60,13 @@ import { isAbsolute, resolve } from 'node:path'
|
||||
/**
|
||||
* Build the policy for one deployment.
|
||||
*
|
||||
* `config` is the *resolved* composition config, so its volatile fields are
|
||||
* references rather than values; every read below goes through
|
||||
* {@link configValue} and happens per use. Holding the config and reading it lazily
|
||||
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
|
||||
* in the Web UI, and a policy that snapshotted them here would keep answering
|
||||
* with the values it was loaded with.
|
||||
*
|
||||
* @param {object} config
|
||||
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
|
||||
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
|
||||
@@ -36,7 +75,20 @@ import { isAbsolute, resolve } from 'node:path'
|
||||
export function createFacetPolicy(config) {
|
||||
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
||||
const grants = new Map()
|
||||
const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : []
|
||||
|
||||
/**
|
||||
* The deployment's standing grants, read fresh on every call.
|
||||
*
|
||||
* Not filtered once at construction: `extraRoots` is volatile, so an edit in
|
||||
* the settings UI replaces the reference's contents without remounting this
|
||||
* plugin, and the next scope resolution is expected to see it.
|
||||
*
|
||||
* @returns {string[]} configured paths, blanks dropped.
|
||||
*/
|
||||
function standingRoots() {
|
||||
const configured = configValue(config.extraRoots)
|
||||
return Array.isArray(configured) ? configured.filter(isPath) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope of one agent, or `undefined` when it has none.
|
||||
@@ -66,7 +118,7 @@ export function createFacetPolicy(config) {
|
||||
function extrasFor(agent, cwd) {
|
||||
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
|
||||
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
|
||||
const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
||||
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
||||
return [...new Set(all)]
|
||||
}
|
||||
|
||||
@@ -124,11 +176,17 @@ export function createFacetPolicy(config) {
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.mode,
|
||||
/** The enforcement mode. A getter, so a settings edit is answered on the next call. */
|
||||
get mode() {
|
||||
return configValue(config.mode)
|
||||
},
|
||||
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
|
||||
sectionOrder: config.sectionOrder ?? 100,
|
||||
contextOrder: config.contextOrder ?? 105,
|
||||
standing: [...standing],
|
||||
sectionOrder: configValue(config.sectionOrder) ?? 100,
|
||||
contextOrder: configValue(config.contextOrder) ?? 105,
|
||||
/** The standing grants. A getter for the same reason as {@link mode}. */
|
||||
get standing() {
|
||||
return standingRoots()
|
||||
},
|
||||
scopeFor,
|
||||
grant,
|
||||
revoke,
|
||||
|
||||
+28
-18
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
|
||||
* treatment the sandbox policy and approval policy already get, for the same
|
||||
* reason.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
||||
* @returns {string} prompt section text, or `''` to contribute nothing.
|
||||
* It takes no configuration, and that is deliberate rather than an oversight.
|
||||
* A system-prompt section is a plain string registered once; there is no
|
||||
* callback to re-render it, and a settings write to a `volatile()` field
|
||||
* updates the config reference *without* re-applying the plugin. So anything
|
||||
* this function interpolated would freeze at whatever the value was at load —
|
||||
* the prompt would go on describing the old `mode` while the guard enforced the
|
||||
* new one. Everything that depends on `mode` therefore lives in the binding,
|
||||
* which is a callback and therefore live.
|
||||
*
|
||||
* @returns {string} prompt section text.
|
||||
*/
|
||||
export function facetRuleText(input) {
|
||||
const { mode } = input
|
||||
if (mode === 'off') return ''
|
||||
|
||||
const reach =
|
||||
mode === 'deny'
|
||||
? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.'
|
||||
: 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.'
|
||||
|
||||
export function facetRuleText() {
|
||||
return [
|
||||
'# Facet scope',
|
||||
'',
|
||||
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.',
|
||||
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which, and says how a call outside it is treated.',
|
||||
'',
|
||||
`Work inside the facet. ${reach}`,
|
||||
'Work inside the facet.',
|
||||
'',
|
||||
'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.',
|
||||
'',
|
||||
@@ -164,9 +163,12 @@ export function displayPath(cwd, path) {
|
||||
/**
|
||||
* The mutable half of the facet, for the runtime-context snapshot.
|
||||
*
|
||||
* Kept to three short lines because it is re-sent as part of the conversation
|
||||
* whenever it changes, and a scope statement that costs more than the scope is
|
||||
* worth is a scope that does not pay for itself.
|
||||
* Kept short because it is re-sent as part of the conversation whenever it
|
||||
* changes, and a scope statement that costs more than the scope is worth is a
|
||||
* scope that does not pay for itself. It carries one sentence more than the bare
|
||||
* binding used to, because `mode` now belongs here: this is the only half that
|
||||
* is re-rendered per request, so it is the only half that can follow a settings
|
||||
* change. See {@link facetRuleText}.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.cwd - the session working directory.
|
||||
@@ -179,13 +181,21 @@ export function facetContextText(input) {
|
||||
const { cwd, root, extras = [], mode } = input
|
||||
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
|
||||
|
||||
// Stated in terms of what the model should *do*, because the same sentence is
|
||||
// what the user reads in an approval prompt. A denial that does not say how to
|
||||
// proceed is a denial the agent works around with `bash`.
|
||||
const reach =
|
||||
mode === 'deny'
|
||||
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
|
||||
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
|
||||
|
||||
// The root is stated absolutely even though it is the session's own directory
|
||||
// and a relative form would be shorter. `facet: .` is true and useless: the
|
||||
// model needs a path it can reason about, and the user reading an approval
|
||||
// prompt needs to see which folder they are being asked about.
|
||||
const lines = [`facet: ${root}`]
|
||||
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
|
||||
lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`)
|
||||
lines.push(`outside the facet: ${reach}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user