Skip to content

Commit 4bc94ef

Browse files
committed
fix(mothership): keep content delivery scoped and consistent
1 parent 99cdb3c commit 4bc94ef

73 files changed

Lines changed: 2606 additions & 1469 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/rules/sim-content-delivery.md‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ Content derived from a provenance-bearing store reaches Chat's model only after
2020
came from is admitted by the gate of the surface it leaves through. The stores are workspace files
2121
(and their versions), run files, knowledge documents and chunks, and table rows. "Derived" covers
2222
bytes, text, excerpts, chunks, cells, previews, base64, archives, rendered or compiled output,
23-
error text that quotes content, public links, and match results.
23+
error text that quotes content, and public links. Counts, ordering, and match locations are ordinary
24+
metadata; this boundary protects supported literal secret values, not arbitrary inference. Intentional
25+
human uploads remain ordinary input and are not scanned.
2426

2527
Only Copilot principals (`delegated` / `organization_delegated` with `serviceId: 'copilot'`) are
2628
classified. Every other caller is admitted without a provenance read, so the UI, API keys, MCP and
@@ -33,6 +35,7 @@ the workflow executor see byte-identical results.
3335
single-item form and throws `ContentRefusedError` (403).
3436
- `admitWrittenContent` is for a committed write that returns content. A refusal or gate failure
3537
does not undo the write: audit and effects run, then the operation answers with the refusal.
38+
Settled tool results use the same deferral, but do not claim that a mutation was saved.
3639
- A source is `{ kind, id, provenance, view }`. `view` is `'complete'` (text whose every entry
3740
may appear), `{ derived: value }` (only entries occurring in `value`), or `'opaque'` (bytes no
3841
redaction reaches: binary, base64, archives, images, public links).
@@ -49,7 +52,7 @@ the workflow executor see byte-identical results.
4952
## Rules
5053

5154
1. **Every operation that admits Copilot declares `contentDelivery`**: `'sourced'` when its result
52-
can carry content from a store (including a match oracle), `'none'` otherwise. Definition throws
55+
can carry content from a store, `'none'` otherwise. Definition throws
5356
without it, and `copilot-route-inventory.test.ts` lists every Chat-reachable route without one.
5457
2. **A `sourced` use case accounts for what it returns**, before it loads content: call
5558
`admitContent` (with `[]` when this call returns none). Otherwise Chat gets
@@ -58,7 +61,7 @@ the workflow executor see byte-identical results.
5861
only when its items cannot be separated (one file, one render, one compile).
5962
4. **A new `sourced` route needs a case in `lib/mothership/__integration__/content-delivery.integration.ts`.**
6063
`copilot-content-routes.json` lists them; the suite fails for a listed route it does not drive.
61-
Refresh the list with `vitest run -u lib/api/server/routes/copilot-route-inventory.test.ts`.
64+
Refresh the list with `vitest run lib/api/server/routes/copilot-route-inventory.test.ts --update=all`.
6265
5. **Do not reach a store's bytes around a use case for Chat.** A helper that reads content for
6366
Chat goes through the gated use case, or names `observeContentOutsideModel` and why.
6467

@@ -70,19 +73,27 @@ own unless noted.
7073
| Path | U | E | R / C |
7174
|---|---|---|---|
7275
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered |
73-
| File download (run_code proxy) and mounts | Refused, not recorded, listed in `excludedInputs` | Recorded in workbench history | Delivered |
76+
| File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered |
77+
| Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered |
7478
| File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered |
75-
| Bulk download zip | Left out | Left out (opaque) | Included |
76-
| `files search` | Results dropped | Results dropped (a match is an oracle) | Returned |
79+
| Bulk download zip | Left out, reported in the archive manifest | Left out (opaque), reported in the archive manifest | Included |
80+
| `files search` | Match location kept; clipped preview withheld | Same; read/grep provides safely redacted text | Returned |
7781
| File edit (`PATCH` content) | 403 before reading | Diagnostics redacted | Unchanged |
7882
| Chat public share activation, and seeing an existing link (any surface, run_code included) | 403 | 403 (a link leaves every gate) | Shares |
7983
| Rendered view, streamed image, inline attachments | Refused | Refused (opaque) | Delivered |
80-
| Doc compile | Compile refused | Error text redacted | Compiles |
84+
| Doc compile | Actual file read refused | Evidence imported before broker bytes/errors return | Compiles, including dynamic file references |
8185
| Run file download | 403 | Redacted | Delivered |
8286
| Run read / execute / resume `base64` | `base64` removed, descriptor kept | Same | Inlined (sync runs only) |
8387
| KB chunks, search, documents, tags | Item dropped (single read: 403) | Redacted | Delivered |
84-
| KB export, table export download | Not available to Chat | | |
88+
| KB export | Stream fails before refused source bytes; no successful archive receipt | Refused (opaque) | Streams originals/chunks with admission at each source |
89+
| Table export download | Existing unsupported Chat operation | | |
8590
| Table rows, query, read, `table_selection` | Row dropped (single read: 403) | Redacted | Delivered |
86-
| Row search | Match dropped | Match in a secret column dropped | Returned |
91+
| Row search | Locations returned without cell values | Same | Returned |
8792
| Table import from a file | Rows stored unknown | Per-cell entries; a row whose coercion rewrote a secret, or whose raw cells cannot be checked, is stored unknown | Exact-empty, as before |
88-
| `tools execute` for file, knowledge, table, memory, function, custom, MCP tools | Not available to Chat | | |
93+
| `tools execute` | Settled result withheld, with no automatic retry | Named masking through the existing registry | Returned, subject to existing tool authorization |
94+
95+
Search pagination and aggregate totals retain their database meaning even when an item or preview is
96+
withheld. Do not scan unrelated table rows or whole document collections to conceal counts.
97+
A refused requested mount stops code before it runs, avoiding an older file at the same sandbox path.
98+
Registry admission commits only when the complete item fits the existing capacity; a refused item
99+
must not consume capacity or permanently disable the parent registry.

‎.cursor/rules/sim-content-delivery.mdc‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ Content derived from a provenance-bearing store reaches Chat's model only after
1111
came from is admitted by the gate of the surface it leaves through. The stores are workspace files
1212
(and their versions), run files, knowledge documents and chunks, and table rows. "Derived" covers
1313
bytes, text, excerpts, chunks, cells, previews, base64, archives, rendered or compiled output,
14-
error text that quotes content, public links, and match results.
14+
error text that quotes content, and public links. Counts, ordering, and match locations are ordinary
15+
metadata; this boundary protects supported literal secret values, not arbitrary inference. Intentional
16+
human uploads remain ordinary input and are not scanned.
1517

1618
Only Copilot principals (`delegated` / `organization_delegated` with `serviceId: 'copilot'`) are
1719
classified. Every other caller is admitted without a provenance read, so the UI, API keys, MCP and
@@ -24,6 +26,7 @@ the workflow executor see byte-identical results.
2426
single-item form and throws `ContentRefusedError` (403).
2527
- `admitWrittenContent` is for a committed write that returns content. A refusal or gate failure
2628
does not undo the write: audit and effects run, then the operation answers with the refusal.
29+
Settled tool results use the same deferral, but do not claim that a mutation was saved.
2730
- A source is `{ kind, id, provenance, view }`. `view` is `'complete'` (text whose every entry
2831
may appear), `{ derived: value }` (only entries occurring in `value`), or `'opaque'` (bytes no
2932
redaction reaches: binary, base64, archives, images, public links).
@@ -40,7 +43,7 @@ the workflow executor see byte-identical results.
4043
## Rules
4144

4245
1. **Every operation that admits Copilot declares `contentDelivery`**: `'sourced'` when its result
43-
can carry content from a store (including a match oracle), `'none'` otherwise. Definition throws
46+
can carry content from a store, `'none'` otherwise. Definition throws
4447
without it, and `copilot-route-inventory.test.ts` lists every Chat-reachable route without one.
4548
2. **A `sourced` use case accounts for what it returns**, before it loads content: call
4649
`admitContent` (with `[]` when this call returns none). Otherwise Chat gets
@@ -49,7 +52,7 @@ the workflow executor see byte-identical results.
4952
only when its items cannot be separated (one file, one render, one compile).
5053
4. **A new `sourced` route needs a case in `lib/mothership/__integration__/content-delivery.integration.ts`.**
5154
`copilot-content-routes.json` lists them; the suite fails for a listed route it does not drive.
52-
Refresh the list with `vitest run -u lib/api/server/routes/copilot-route-inventory.test.ts`.
55+
Refresh the list with `vitest run lib/api/server/routes/copilot-route-inventory.test.ts --update=all`.
5356
5. **Do not reach a store's bytes around a use case for Chat.** A helper that reads content for
5457
Chat goes through the gated use case, or names `observeContentOutsideModel` and why.
5558

@@ -61,19 +64,27 @@ own unless noted.
6164
| Path | U | E | R / C |
6265
|---|---|---|---|
6366
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered |
64-
| File download (run_code proxy) and mounts | Refused, not recorded, listed in `excludedInputs` | Recorded in workbench history | Delivered |
67+
| File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered |
68+
| Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered |
6569
| File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered |
66-
| Bulk download zip | Left out | Left out (opaque) | Included |
67-
| `files search` | Results dropped | Results dropped (a match is an oracle) | Returned |
70+
| Bulk download zip | Left out, reported in the archive manifest | Left out (opaque), reported in the archive manifest | Included |
71+
| `files search` | Match location kept; clipped preview withheld | Same; read/grep provides safely redacted text | Returned |
6872
| File edit (`PATCH` content) | 403 before reading | Diagnostics redacted | Unchanged |
6973
| Chat public share activation, and seeing an existing link (any surface, run_code included) | 403 | 403 (a link leaves every gate) | Shares |
7074
| Rendered view, streamed image, inline attachments | Refused | Refused (opaque) | Delivered |
71-
| Doc compile | Compile refused | Error text redacted | Compiles |
75+
| Doc compile | Actual file read refused | Evidence imported before broker bytes/errors return | Compiles, including dynamic file references |
7276
| Run file download | 403 | Redacted | Delivered |
7377
| Run read / execute / resume `base64` | `base64` removed, descriptor kept | Same | Inlined (sync runs only) |
7478
| KB chunks, search, documents, tags | Item dropped (single read: 403) | Redacted | Delivered |
75-
| KB export, table export download | Not available to Chat | | |
79+
| KB export | Stream fails before refused source bytes; no successful archive receipt | Refused (opaque) | Streams originals/chunks with admission at each source |
80+
| Table export download | Existing unsupported Chat operation | | |
7681
| Table rows, query, read, `table_selection` | Row dropped (single read: 403) | Redacted | Delivered |
77-
| Row search | Match dropped | Match in a secret column dropped | Returned |
82+
| Row search | Locations returned without cell values | Same | Returned |
7883
| Table import from a file | Rows stored unknown | Per-cell entries; a row whose coercion rewrote a secret, or whose raw cells cannot be checked, is stored unknown | Exact-empty, as before |
79-
| `tools execute` for file, knowledge, table, memory, function, custom, MCP tools | Not available to Chat | | |
84+
| `tools execute` | Settled result withheld, with no automatic retry | Named masking through the existing registry | Returned, subject to existing tool authorization |
85+
86+
Search pagination and aggregate totals retain their database meaning even when an item or preview is
87+
withheld. Do not scan unrelated table rows or whole document collections to conceal counts.
88+
A refused requested mount stops code before it runs, avoiding an older file at the same sandbox path.
89+
Registry admission commits only when the complete item fits the existing capacity; a refused item
90+
must not consume capacity or permanently disable the parent registry.

‎apps/sim/app/api/v2/files/bulk-download/route.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe('GET /api/v2/files/bulk-download', () => {
7676
folderPaths: new Map<string, string>(),
7777
renderedDocuments: new Map<string, Buffer>(),
7878
declaredBytes: 6,
79+
excludedFileIds: [],
7980
})
8081
})
8182

‎apps/sim/app/api/v2/files/bulk-download/route.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@ export const GET = defineV2BinaryRoute({
5454
folderPaths: query.folderPaths,
5555
}),
5656
useCase: downloadWorkspaceFileItems,
57-
present: ({ filesToZip, folderPaths, renderedDocuments }) => {
58-
const entryPaths = buildZipEntryPaths(
59-
filesToZip.map((file) => ({
57+
present: ({ filesToZip, folderPaths, renderedDocuments, excludedFileIds }) => {
58+
const report = excludedFileIds.length > 0 ? { partial: true, excludedFileIds } : undefined
59+
const entryPaths = buildZipEntryPaths([
60+
...filesToZip.map((file) => ({
6061
name: file.name,
6162
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
6263
contentType: file.type,
63-
}))
64-
)
64+
})),
65+
...(report ? [{ name: 'sim-download-report.json', contentType: 'application/json' }] : []),
66+
])
6567
const archive = new ZipArchive({ store: true })
6668
archive.on('warning', (error: Error) => {
6769
logger.warn('Archive warning while streaming workspace files', { error })
@@ -71,6 +73,9 @@ export const GET = defineV2BinaryRoute({
7173
name: entryPaths[index],
7274
})
7375
})
76+
if (report) {
77+
archive.append(JSON.stringify(report, null, 2), { name: entryPaths[filesToZip.length] })
78+
}
7479
archive.finalize().catch((error) => {
7580
logger.error('Failed to finalize workspace file archive', { error })
7681
})

‎apps/sim/executor/utils/resolved-secret-trace-registry.ts‎

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,6 +1731,41 @@ export class ResolvedSecretTraceRegistry {
17311731
return importedAll
17321732
}
17331733

1734+
/** Imports one item's trusted evidence atomically, leaving this registry unchanged on refusal. */
1735+
async tryImportProvenance(
1736+
provenances: Iterable<ResolvedSecretTraceProvenanceV1>,
1737+
options: ImportResolvedSecretTraceProvenanceOptions & { value?: unknown }
1738+
): Promise<boolean> {
1739+
const staged = new ResolvedSecretTraceRegistry([], this.scope, { staged: true })
1740+
for (const provenance of provenances) {
1741+
if (!(await staged.importProvenance(provenance, options)) || !staged.isComplete())
1742+
return false
1743+
}
1744+
1745+
let entries = [...staged.activeEntries.values()]
1746+
if (options.value !== undefined) {
1747+
const filtered = staged.exportProvenanceForValue(options.value)
1748+
if (!filtered.complete) return false
1749+
const selected = new Set(
1750+
filtered.entries.map((entry) =>
1751+
activeEntryKey({
1752+
name: entry.name ?? '',
1753+
plaintext: '',
1754+
encryptedValue: entry.encryptedValue,
1755+
anonymous: entry.name === undefined,
1756+
})
1757+
)
1758+
)
1759+
entries = entries.filter((entry) => selected.has(activeEntryKey(entry)))
1760+
}
1761+
1762+
/** No await separates the capacity check from activation, including across concurrent calls. */
1763+
if (this.isPermanentlyIncomplete() || this.activeEntryBytesAfter(entries) === undefined)
1764+
return false
1765+
for (const entry of entries) this.addActiveEntry(entry, { propagated: true })
1766+
return true
1767+
}
1768+
17341769
/**
17351770
* Imports only provenance whose decrypted plaintext occurs in the exact value crossing this
17361771
* boundary. Staging in a scoped temporary registry prevents a valid but overbroad envelope from
@@ -2578,6 +2613,22 @@ export class ResolvedSecretTraceRegistry {
25782613
if (copied) target.inheritIncompletenessReasonsFrom(this)
25792614
}
25802615

2616+
private activeEntryBytesAfter(entries: Iterable<ActiveSecretEntry>): number | undefined {
2617+
let count = this.activeEntries.size
2618+
let bytes = this.activeProvenanceEntryBytes
2619+
for (const entry of entries) {
2620+
if (this.activeEntries.has(activeEntryKey(entry))) continue
2621+
bytes += (count === 0 ? 0 : 1) + serializedProvenanceEntryByteSize(toProvenanceEntry(entry))
2622+
count += 1
2623+
if (
2624+
count > MAX_PROVENANCE_ENTRIES ||
2625+
this.completeProvenanceEnvelopeBytes + bytes > MAX_SERIALIZED_PROVENANCE_BYTES
2626+
)
2627+
return undefined
2628+
}
2629+
return bytes
2630+
}
2631+
25812632
private addActiveEntry(entry: ActiveSecretEntry, options: { propagated?: boolean } = {}): void {
25822633
const key = activeEntryKey(entry)
25832634
const existing = this.activeEntries.get(key)
@@ -2596,22 +2647,14 @@ export class ResolvedSecretTraceRegistry {
25962647
return
25972648
}
25982649

2599-
const entryBytes = serializedProvenanceEntryByteSize(toProvenanceEntry(entry))
2600-
const separatorBytes = this.activeEntries.size === 0 ? 0 : 1
2601-
if (
2602-
this.activeEntries.size >= MAX_PROVENANCE_ENTRIES ||
2603-
this.completeProvenanceEnvelopeBytes +
2604-
this.activeProvenanceEntryBytes +
2605-
separatorBytes +
2606-
entryBytes >
2607-
MAX_SERIALIZED_PROVENANCE_BYTES
2608-
) {
2650+
const nextEntryBytes = this.activeEntryBytesAfter([entry])
2651+
if (nextEntryBytes === undefined) {
26092652
this.markIncomplete('provenance-capacity-exceeded')
26102653
return
26112654
}
26122655
this.activeEntries.set(key, entry)
26132656
if (options.propagated) this.propagatedEntryKeys.add(key)
2614-
this.activeProvenanceEntryBytes += separatorBytes + entryBytes
2657+
this.activeProvenanceEntryBytes = nextEntryBytes
26152658
this.modelEgressRevision += 1
26162659
}
26172660

‎apps/sim/lib/api/server/routes/copilot-content-routes.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
},
8787
{
8888
"method": "GET",
89-
"path": "/api/v2/knowledge/[knowledgeBaseId]/tags/usage",
90-
"operation": "knowledge.tags.read_usage"
89+
"path": "/api/v2/knowledge/[knowledgeBaseId]/export",
90+
"operation": "knowledge.export"
9191
},
9292
{
9393
"method": "POST",
@@ -131,13 +131,13 @@
131131
},
132132
{
133133
"method": "POST",
134-
"path": "/api/v2/tables/[tableId]/rows/search",
135-
"operation": "tables.rows.search"
134+
"path": "/api/v2/tables/[tableId]/rows/upsert",
135+
"operation": "tables.rows.upsert"
136136
},
137137
{
138138
"method": "POST",
139-
"path": "/api/v2/tables/[tableId]/rows/upsert",
140-
"operation": "tables.rows.upsert"
139+
"path": "/api/v2/tools/[toolId]/execute",
140+
"operation": "tools.execute"
141141
},
142142
{
143143
"method": "POST",

‎apps/sim/lib/api/server/routes/copilot-refused-routes.json‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@
1414
"path": "/api/v2/files/[fileId]/versions/[version]/content",
1515
"operation": "files.versions.download"
1616
},
17-
{
18-
"method": "GET",
19-
"path": "/api/v2/knowledge/[knowledgeBaseId]/export",
20-
"operation": "knowledge.export"
21-
},
2217
{
2318
"method": "GET",
2419
"path": "/api/v2/meta",

0 commit comments

Comments
 (0)