feat(ai-chat): disclose an app's exposure on deploy, refuse drag-and-drop apps (#11137)

* feat(ai-chat): disclose an app's exposure on deploy, refuse drag-and-drop apps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* refactor(ai-chat): leave the app kind check at deploy to the server

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* feat(ai-chat): disclose a guest deploy where the instance admits guests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* test(ai-chat): pin each guest switch in the deploy disclosure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* docs(ai-chat): describe what a staged draft over a low-code app actually breaks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* feat(ai-chat): report who may open an app when reading it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* fix(ai-chat): report a guest app as inert where no guest is admitted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* fix(ai-chat): report who may open an app that has a draft over it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

* docs(ai-chat): say which mode a drafted app read reports

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-21 09:47:48 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 6186a0645d
commit 85b36a2de5
5 changed files with 297 additions and 8 deletions
@@ -106,6 +106,13 @@ const CATALOG = [
path: '/w/{workspace}/scripts/get/p/{path}',
method: 'GET'
},
{
name: 'getAppByPath',
description: 'Get app by path',
instructions: 'Returns the app source',
path: '/w/{workspace}/apps/get/p/{path}',
method: 'GET'
},
{
name: 'deleteScriptByHash',
description: 'Delete a script by hash',
@@ -260,12 +267,12 @@ describe('call_api_get', () => {
})
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
for (const name of ['getScriptByPath', 'getResource', 'getSchedule', 'getAppByPath']) {
const result = await run('call_api_get', { name })
expect(result.success).toBe(false)
expect(result.error).toContain('read_workspace_item')
}
for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules']) {
for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules', 'listApps']) {
const result = await run('call_api_get', { name })
expect(result.success).toBe(false)
expect(result.error).toContain('list_workspace_items')
@@ -273,6 +280,9 @@ describe('call_api_get', () => {
const search = await run('search_api_endpoints', { query: 'get script' })
expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath')
// getAppByPath would hand the model the whole app source, so it must not even surface.
const appSearch = await run('search_api_endpoints', { query: 'get app' })
expect(appSearch.matches.map((m: any) => m.name)).not.toContain('getAppByPath')
})
it('refuses the worker and data-metric reads, pointing at their dedicated tools', async () => {
@@ -32,6 +32,11 @@ const COVERED_ENDPOINTS: Record<string, string> = {
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
getSchedule:
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
// getAppByPath returns the entire app source — every file, every runnable's
// script, lock and schema — where read_workspace_item returns paths and sizes.
getAppByPath:
'read_workspace_item (app metadata only; read_app_file for contents. Reads your draft when one exists; pass version: "deployed" for the deployed state)',
listApps: 'list_workspace_items (it includes your drafts)',
listScripts: 'list_workspace_items (it includes your drafts)',
listFlows: 'list_workspace_items (it includes your drafts)',
listResource: 'list_workspace_items (it includes your drafts)',
@@ -259,6 +259,11 @@ vi.mock('$lib/gen', async () => {
WorkerService: wrapService(actual.WorkerService, {
listWorkers: vi.fn(async () => [])
}),
// Guests off by default, as a fresh workspace has them.
WorkspaceService: wrapService(actual.WorkspaceService, {
getGuestUsage: vi.fn(async () => ({ available: false, instance_enabled: false })),
getPublicSettings: vi.fn(async () => ({ guest_access_enabled: false }))
}),
FolderService: wrapService(actual.FolderService, {
createFolder: vi.fn(async () => 'created')
}),
@@ -404,7 +409,8 @@ import {
ScriptService,
UserService,
VariableService,
WorkerService
WorkerService,
WorkspaceService
} from '$lib/gen'
import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores'
import { processSecretArgs } from '$lib/components/secretArgUtils'
@@ -3590,6 +3596,26 @@ describe('global AI tools', () => {
})
})
// The draft carries the deployed policy from the fork, so nothing is fetched to answer.
it('reports exposure for an app that has a draft over it', async () => {
seedBackendDraft(
'raw_app',
'f/apps/drafted',
{
files: { '/src/App.tsx': 'x' },
runnables: {},
policy: { execution_mode: 'anonymous' }
} as any,
{ workspace: WORKSPACE }
)
const read = await callGlobalTool('read_workspace_item', {
type: 'app',
path: 'f/apps/drafted'
})
expect(JSON.parse(read)).toMatchObject({ isDraft: true, executionMode: 'anonymous' })
})
it('summarizes local raw app drafts in read_workspace_item', async () => {
seedBackendDraft(
'raw_app',
@@ -3705,6 +3731,91 @@ describe('global AI tools', () => {
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
// A low-code app has a grid, not files and runnables, so every app tool here would
// otherwise report it as empty rather than say it is the wrong kind of app.
it('reports a low-code app without its contents, and refuses to act on it', async () => {
const lowCode = {
path: 'f/apps/legacy',
summary: 'legacy app',
raw_app: false,
value: { grid: [{ id: 'a', data: { type: 'buttoncomponent' } }] }
} as any
// ...Once per call: a persistent implementation would outlive this test and
// disarm the factory's "mock not configured" guard for the rest of the file.
for (let i = 0; i < 3; i++) vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(lowCode)
// The read answers "this app is drag-and-drop" rather than throwing — but it must not
// summarize a grid as a file/runnable list, which reads as an empty app.
const read = JSON.parse(
await callGlobalTool('read_workspace_item', { type: 'app', path: 'f/apps/legacy' })
)
expect(read).toMatchObject({ type: 'app', path: 'f/apps/legacy', rawApp: false })
expect(read).not.toHaveProperty('value')
// The tools that would convert it to files and runnables still refuse: a staged draft
// would answer every later read in place of the app itself.
for (const [tool, args] of [
['read_app_file', { path: 'f/apps/legacy', file_path: '/index.tsx' }],
['write_app_file', { path: 'f/apps/legacy', file_path: '/App.tsx', content: 'x' }]
] as [string, any][]) {
const raw = await callGlobalTool(tool, args).catch((e) => String(e))
expect(raw).toContain('low-code app')
}
expect(getBackendDraft('raw_app', 'f/apps/legacy', { workspace: WORKSPACE })).toBeUndefined()
})
// getAppByPath and listApps are refused by the catalog, so this read is the only way
// left to ask who may open a deployed app. Both kinds answer: a drag-and-drop app can
// be anonymous too, and no other tool here can inspect one.
it('reports who may open an app, whichever kind it is', async () => {
const readMode = async (app: any) => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(app)
const read = await callGlobalTool('read_workspace_item', { type: 'app', path: app.path })
return JSON.parse(read).executionMode
}
const guestApp = {
path: 'f/apps/code',
summary: 'Code app',
raw_app: true,
value: { files: {}, runnables: {} },
policy: { execution_mode: 'guest' }
}
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
available: true,
instance_enabled: true
} as any)
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
guest_access_enabled: true
} as any)
expect(await readMode(guestApp)).toBe('guest')
expect(
await readMode({
path: 'f/apps/builder',
summary: 'Builder app',
raw_app: false,
value: { grid: [] },
policy: { execution_mode: 'anonymous' }
})
).toBe('anonymous')
// The same app, with each switch crossed in turn: either one alone admits nobody, so
// reporting the mode bare would name an exposure the server refuses. Both off needs
// no case of its own — whichever half of the check were dropped, one of these two
// still catches it.
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
guest_access_enabled: true
} as any)
expect(await readMode(guestApp)).toContain('inert')
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
available: true,
instance_enabled: true
} as any)
expect(await readMode(guestApp)).toContain('inert')
})
it('reads raw app files without creating a draft', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
@@ -4361,6 +4472,69 @@ describe('global AI tools', () => {
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
// Deploying is what makes an app's runnables reachable, so it is the one moment the
// exposure is both true and known.
it('discloses who can open an app after a deploy', async () => {
const deployApp = async (path: string, policy: Record<string, unknown>) => {
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any)
seedBackendDraft(
'raw_app',
path,
{
summary: 'App',
files: { '/index.tsx': 'x' },
runnables: {},
data: { tables: [] },
policy
},
{ workspace: WORKSPACE }
)
return JSON.parse(await callGlobalTool('deploy_workspace_item', { type: 'app', path }))
}
const open = await deployApp('f/apps/open', {
execution_mode: 'anonymous',
on_behalf_of: 'u/alice'
})
expect(open.success).toBe(true)
expect(open.message).toContain('anyone with the URL, without logging in')
// The server decides what a deployed app runs as — it overwrites on_behalf_of for
// anyone outside the deployers group — so the note must not name an identity.
expect(open.message).not.toContain('u/alice')
// Guest is a real widening only where the deployment, the instance and the
// workspace all admit guests; stored below that, it is inert.
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
available: true,
instance_enabled: true
} as any)
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
guest_access_enabled: true
} as any)
const guestOn = await deployApp('f/apps/guest', { execution_mode: 'guest' })
expect(guestOn.success).toBe(true)
expect(guestOn.message).toContain('identity provider authenticates')
// Each switch crossed in turn, because either one alone admits nobody and the note
// would then announce an exposure that does not exist. Both off needs no case of its
// own: whichever half of the check were dropped, one of these two still catches it.
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
guest_access_enabled: true
} as any)
const instanceOff = await deployApp('f/apps/guest_inst_off', { execution_mode: 'guest' })
expect(instanceOff.success).toBe(true)
expect(instanceOff.message).not.toContain('identity provider')
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
available: true,
instance_enabled: true
} as any)
const workspaceOff = await deployApp('f/apps/guest_ws_off', { execution_mode: 'guest' })
expect(workspaceOff.success).toBe(true)
expect(workspaceOff.message).not.toContain('identity provider')
})
it('forwards preserve_on_behalf_of when the deployed policy carries an on_behalf_of', async () => {
// Without the flag the backend resets the policy's on_behalf_of to the
// deploying user; this chat path has no on-behalf-of selector, so it must
@@ -19,7 +19,8 @@ import {
SqsTriggerService,
VariableService,
WebsocketTriggerService,
WorkerService
WorkerService,
WorkspaceService
} from '$lib/gen'
import { createTwoFilesPatch } from 'diff'
import { deepEqual } from 'fast-equals'
@@ -1421,8 +1422,9 @@ Flows:
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.
Raw apps:
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app, which you can list and read but not edit or deploy. Check it before offering to change an app.
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
- Use init_app only after confirming framework, path, and summary with the user.
@@ -1528,6 +1530,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
summary: item.summary,
value: summarizeAppValue(item.value as AppDraftValue),
rawApp: item.rawApp,
executionMode: item.executionMode,
isDraft: item.isDraft
}
}
@@ -1824,13 +1827,28 @@ function getInlineRunnableContent(
return { content: runnable.inlineScript?.content ?? '', runnable }
}
// appSourceToDraftValue drops a low-code app's `grid`, so converting one here would stage a
// code-app draft at its path: a false picture of the app that every later read answers from,
// and a deploy the server then refuses.
async function getRawAppByPath(workspace: string, path: string): Promise<AppWithLastVersion> {
const app = await AppService.getAppByPath({ workspace, path })
// Only an explicit false: the draft-only branch of get_app carries no version, and so
// no `raw_app`, and must not read as low-code.
if (app.raw_app === false) {
throw new Error(
`"${path}" is a low-code app. This chat only edits code-based apps — open it in the app editor instead.`
)
}
return app
}
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
const draft = await getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return draft.value as AppDraftValue
}
const app = await AppService.getAppByPath({ workspace, path })
const app = await getRawAppByPath(workspace, path)
return appSourceToDraftValue(app, app)
}
@@ -1840,7 +1858,7 @@ async function loadAppDraftValue(path: string, workspace: string): Promise<Loade
return { value: draft.value as AppDraftValue }
}
const app = await AppService.getAppByPath({ workspace, path })
const app = await getRawAppByPath(workspace, path)
return { value: appSourceToDraftValue(app, app) }
}
@@ -1974,6 +1992,35 @@ const triggerServices: Record<TriggerKind, TriggerService> = {
}
}
/** Whether the server would admit a guest here: the deployment has to support guests at
* all, and the instance and workspace switches both have to be on. `guest` is stored on
* an app even when none of that holds, so the mode alone never settles who can open it.
* `undefined` when a switch read fails neither proven live nor proven inert. */
async function guestAccessIsLive(workspace: string): Promise<boolean | undefined> {
const [usage, settings] = await Promise.all([
WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined),
WorkspaceService.getPublicSettings({ workspace }).catch(() => undefined)
])
if (usage === undefined || settings === undefined) {
return undefined
}
return !!(usage.available && usage.instance_enabled && settings.guest_access_enabled)
}
/** Who may open an app, from the mode stored on it. An app sits in `guest` mode whether
* or not anyone is admitted by it, so reporting the mode bare would say strangers can
* open an app that admits members only. Say it is inert rather than hide it, as the
* app's deploy settings do. */
async function describeAppExposure(
workspace: string,
mode: string | undefined
): Promise<string | undefined> {
if (mode !== 'guest' || (await guestAccessIsLive(workspace)) !== false) {
return mode
}
return 'guest, but inert: the instance or workspace admits no guest, so this app admits members only'
}
async function readWorkspaceItem(
type: WorkspaceItemType,
path: string,
@@ -2036,6 +2083,19 @@ async function readWorkspaceItem(
case 'app': {
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
const app = await AppService.getAppByPath({ workspace, path })
// A grid is not files and runnables: summarizing one reports an empty app. Name the
// kind instead.
const executionMode = await describeAppExposure(workspace, app.policy?.execution_mode)
if (app.raw_app === false) {
return {
type: 'app',
path: app.path,
summary: app.summary,
rawApp: false,
executionMode,
isDraft: false
}
}
const value = appSourceToDraftValue(app)
const metadata = summarizeAppValue(value)
return {
@@ -2044,6 +2104,7 @@ async function readWorkspaceItem(
summary: value.summary,
value: metadata as unknown as AppDraftValue,
rawApp: app.raw_app,
executionMode,
isDraft: false
}
}
@@ -3468,7 +3529,17 @@ export const globalTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, {
content: `Read draft ${parsed.type} "${parsed.path}"`
})
return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2)
// The mode reported is the draft's, which is what deploying it will write. The
// deployed app's own mode is a different question, asked with version:
// "deployed", and must not be fetched here.
const executionMode =
parsed.type === 'app'
? await describeAppExposure(
workspace,
(draft.value as AppDraftValue)?.policy?.execution_mode
)
: undefined
return JSON.stringify(serializeWorkspaceItemForRead({ ...draft, executionMode }), null, 2)
}
toolCallbacks.setToolStatus(toolId, {
@@ -7990,6 +8061,27 @@ async function deployDraft(
`not working until they are.`
}
// `policy` is the mode being written, so the exposure is stated from the value in
// hand. What a runnable runs as is the server's to decide, so the note names who
// can reach the app and never an identity.
if (policy.execution_mode === 'anonymous') {
deployNote =
`${deployNote ? `${deployNote} ` : ''}This app is deployed as anonymous: its ` +
`backend runnables are now reachable by anyone with the URL, without logging in. ` +
`Tell the user plainly what is now reachable and by whom.`
} else if (policy.execution_mode === 'guest') {
// Only where the guest door actually opens: below that, silence — a false note
// is worse than none. The standing cap is a live count no read settles, so the
// note says the door is open, not that every newcomer gets in.
if (await guestAccessIsLive(workspace)) {
deployNote =
`${deployNote ? `${deployNote} ` : ''}This app is deployed as guest: anyone ` +
`the instance's identity provider authenticates can now open it and run its ` +
`backend runnables, member of this workspace or not. Tell the user plainly ` +
`what is now reachable and by whom.`
}
}
toolCallbacks.setToolStatus(toolId, {
content: `Bundling app "${path}"...`
})
@@ -143,6 +143,14 @@ export type WorkspaceItem = {
* editor. The two are edited by disjoint tool sets, so the distinction has to
* reach the model before it picks one. */
rawApp?: boolean
/** Apps only. Who may open the app — read from a draft, who may open it once
* that draft is deployed: `anonymous` is anyone with the URL and no login,
* `guest` anyone the identity provider authenticates, the rest a workspace
* member. A `guest` app whose instance or workspace admits
* no guest says so here, since the mode is stored either way. Reported per
* app, not in listings — a listing carrying it would make absence read as
* proof of no exposure. */
executionMode?: string
isDraft: boolean
isLiveDraft?: boolean
}