Skip to content
Closed
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
25 changes: 25 additions & 0 deletions apps/sim/lib/file-parsers/json-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,29 @@ describe('JSON parser complexity limits', () => {
code: 'invalid_format',
})
})

/**
* Text extraction errors reach Chat as the read's error, and egress redaction recognizes only
* whole secrets, so a message quoting a clipped excerpt of the file could carry part of one.
*/
it.each([
[
'JSON',
parseJSONBuffer,
`{"config": {"key": sk_live_FIXTURE_0123456789abcdefghij${'x'.repeat(200)}}}`,
],
[
'JSONL',
parseJSONLBuffer,
`{"ok": true}\n{"key": "${'x'.repeat(80)}sk_live_FIXTURE_0123456789abcdefghij`,
],
] as const)(
'names where invalid %s fails without quoting the file',
async (_kind, parse, text) => {
const error = await parse(Buffer.from(text)).catch((caught: unknown) => caught)
expect(error).toMatchObject({ code: 'invalid_format' })
expect(String((error as Error).message)).not.toContain('sk_l')
expect(String((error as Error).message)).not.toContain('xxxx')
}
)
})
16 changes: 14 additions & 2 deletions apps/sim/lib/file-parsers/json-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ export function stripJsonComments(text: string): string {
return out.replace(/,(\s*[}\]])/g, '$1')
}

/** V8's excerpt of the input: `Unexpected token 's', "…{"key": sk_l"... is not valid JSON`. */
const QUOTED_INPUT_EXCERPT = /,\s*(?:\.\.\.)?".*"(?:\.\.\.)?\s+is not valid JSON$/s

/**
* A parse failure's message without the clipped excerpt of the input V8 quotes in it. The message
* reaches Chat as the read's error, where redaction matches whole secrets only, so an excerpt cut
* through a secret would carry the part that survived the cut.
*/
function describeSyntaxError(error: unknown): string {
return getErrorMessage(error, 'Unknown error').replace(QUOTED_INPUT_EXCERPT, '')

@cubic-dev-ai cubic-dev-ai Bot Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: describeSyntaxError removes the quoted excerpt but leaves V8’s Unexpected token 's' prefix, so malformed JSON starting with a secret still sends its first character to Chat. Return a generic syntax diagnostic instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/file-parsers/json-parser.ts, line 225:

<comment>`describeSyntaxError` removes the quoted excerpt but leaves V8’s `Unexpected token 's'` prefix, so malformed JSON starting with a secret still sends its first character to Chat. Return a generic syntax diagnostic instead.</comment>

<file context>
@@ -213,6 +213,18 @@ export function stripJsonComments(text: string): string {
+ * through a secret would carry the part that survived the cut.
+ */
+function describeSyntaxError(error: unknown): string {
+  return getErrorMessage(error, 'Unknown error').replace(QUOTED_INPUT_EXCERPT, '')
+}
+
</file context>
Suggested change
return getErrorMessage(error, 'Unknown error').replace(QUOTED_INPUT_EXCERPT, '')
return 'Invalid JSON syntax'
Fix with cubic

}

function parseJsonContent(buffer: Uint8Array): FileParseResult {
const decoded = decodeTextBuffer(buffer)
try {
Expand All @@ -226,7 +238,7 @@ function parseJsonContent(buffer: Uint8Array): FileParseResult {
if (lenient) return lenient
throw new FileParserError(
'invalid_format',
`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`,
`Invalid JSON: ${describeSyntaxError(error)}`,
error
)
}
Expand Down Expand Up @@ -282,7 +294,7 @@ function parseJsonLinesContent(buffer: Uint8Array): FileParseResult {
} catch (error) {
throw new FileParserError(
'invalid_format',
`Invalid JSONL on line ${lineNumber}: ${line.slice(0, 100)}`,
`Invalid JSONL on line ${lineNumber}: ${describeSyntaxError(error)}`,
error
)
}
Expand Down
45 changes: 42 additions & 3 deletions apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import { escapeRegExp } from '@sim/utils/string'
import type { ReadFileTextResponse } from 'sim/embed'
import { listCatalogTools } from '@/lib/catalog/application/list-tools'
import { readBlockCatalog } from '@/lib/catalog/application/read-block-catalog'
Expand All @@ -14,6 +14,9 @@ import {
agentCliOk,
} from '@/lib/mothership/agent-cli/types'
import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import { OPAQUE_RESOLVED_SECRET_REPLACEMENT } from '@/executor/utils/resolved-secret-matcher'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

/**
* `grep <pattern> [--scope a,b] [--in <world|id|name|world/id>] [-i] [-C n] [-A n] [-B n] [--count] [--limit n]` —
Expand Down Expand Up @@ -430,8 +433,36 @@ function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boo
return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle)
}

/** What redaction leaves in place of a secret: a `{{NAME}}` label or the opaque marker. */
const REDACTION_MARKER = new RegExp(
`\\{\\{[^{}]+\\}\\}|${escapeRegExp(OPAQUE_RESOLVED_SECRET_REPLACEMENT)}`,
'g'
)

/** Clips redacted text, cutting before a redaction marker rather than through it. */
function clip(line: string): string {
return truncate(line, MAX_LINE_CHARS, '… [line truncated]')
if (line.length <= MAX_LINE_CHARS) return line
let cut = MAX_LINE_CHARS
for (const marker of line.matchAll(REDACTION_MARKER)) {
if (marker.index >= cut) break
if (marker.index + marker[0].length > cut) {
cut = marker.index
break
}
}
return `${line.slice(0, cut)}… [line truncated]`
}

/**
* Model egress redacts whole secrets only, so a line clipped, or a multi-line secret split into
* lines, would carry a fragment it cannot recognize. The text is redacted whole first, through the
* same provenance egress uses; null means it could not be, and the resource is not searched.
* Without a registry egress withholds the whole result, so there is nothing to redact against.
*/
function redactForModel(text: string, egress: ResolvedSecretTraceRegistry | undefined) {
if (!egress) return text
const projection = projectResolvedSecretModelContent(text, egress)
return projection.safe && typeof projection.value === 'string' ? projection.value : null
}

function didYouMean(scope: string): string {
Expand Down Expand Up @@ -587,9 +618,17 @@ export const universalGrepCommand: AgentCliEngine = {
}
return now
}
const egress = runtime.resolvedSecretTraceRegistry?.forkForPropagatedEntries()
for (const resource of candidates) {
if (resource.text === null) continue
const lines = resource.text.split('\n')
const text = redactForModel(resource.text, egress)
if (text === null) {
bounded.reportIncomplete(
`${resource.scope}/${resource.label} (${resource.id}): could not be checked for secret values, so it was not searched.`
)
continue
}
const lines = text.split('\n')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Matches point to wrong lines

Replacing a multi-line secret with one marker removes its newlines before grep numbers the lines. In the new fixture, searching for config end reports line 3 instead of line 6. Every later match shifts too, and context windows cover different source lines. Following a reported location can therefore read the wrong part of the file.

Keep redaction before matching, but preserve the original line positions or map the redacted positions back to them.

const selected = new Map<number, boolean>()
const remainingLines = limit - out.length
let nextContextLine = 0
Expand Down
83 changes: 83 additions & 0 deletions apps/sim/lib/mothership/agent-cli/file-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,89 @@ describe('file provenance at the actual CLI and model-result boundary', () => {
)
})

describe('grep reshapes a file line only after redacting it', () => {
const secret = 'sk_live_P3_FIXTURE_0123456789abcdefghij'
const otherSecret = 'pk_test_P3_FIXTURE_ZYXWVUTSRQPONMLKJIH'
const keyBlock = [
'-----BEGIN FIXTURE KEY-----',
'MIIBFIXTUREKEYLINEONE0123456789',
'MIIBFIXTUREKEYLINETWO0123456789',
'-----END FIXTURE KEY-----',
].join('\n')
const LINE_CHARS = 2_000
const pad = (from: number, to: number) => 'x'.repeat(to - from)

/** A secret leaks as soon as its first or last four characters, or one of its lines, cross. */
function fragments(value: string): string[] {
return [value.slice(0, 4), value.slice(-4), ...value.split('\n')]
}

async function grepSecretFile(text: string, pattern: string) {
const plaintexts: Record<string, string> = {
'cipher-file': secret,
'cipher-other': otherSecret,
'cipher-key': keyBlock,
}
mocks.decrypt.mockImplementation(async (cipher: string) => ({
decrypted: plaintexts[cipher],
}))
mocks.buffer.mockResolvedValue(Buffer.from(text))
vi.stubGlobal('fetch', async () =>
Response.json({
data: [{ ...file, size: Buffer.byteLength(text), updatedAt: revision.toISOString() }],
nextCursor: null,
})
)
resetDbChainMock()
queueTableRows(workspaceFiles, [
sidecarRow('exact', revision, [
{ name: 'FILE_SECRET', encryptedValue: 'cipher-file', sourceUserId: 'reader' },
{ name: 'OTHER_SECRET', encryptedValue: 'cipher-other', sourceUserId: 'reader' },
{ name: 'KEY_BLOCK', encryptedValue: 'cipher-key', sourceUserId: 'reader' },
]),
])
const trace = registry()
const result = await runCli(grepFiles(pattern), trace)
const projection = inspectToolResultForCopilot(result, trace, 'sim_cli')
expect(projection.safe).toBe(true)
return JSON.stringify(projection.result)
}

it.each([
['straddling the clip', `NEEDLE ${pad(7, LINE_CHARS - 10)}${secret} tail`],
[
'whose redaction marker would straddle the clip',
`NEEDLE ${pad(7, LINE_CHARS - 5)}${secret}`,
],
['wholly past the clip', `NEEDLE ${pad(7, LINE_CHARS + 50)}${secret} tail`],
[
'repeated around the clip with another secret',
`NEEDLE ${otherSecret} ${pad(48, LINE_CHARS - 12)}${secret}${otherSecret}${secret}`,
],
])('never returns a fragment of a secret %s', async (_case, line) => {
const projected = await grepSecretFile(`${line}\nsecond line`, 'NEEDLE')
expect(projected).toContain('NEEDLE')
expect(projected).toContain('[line truncated]')
for (const fragment of [...fragments(secret), ...fragments(otherSecret)]) {
expect(projected).not.toContain(fragment)
}
const withoutMarkers = projected
.replaceAll('{{FILE_SECRET}}', '')
.replaceAll('{{OTHER_SECRET}}', '')
.replaceAll('[REDACTED_SECRET]', '')
expect(withoutMarkers).not.toContain('{{')
expect(withoutMarkers).not.toContain('[RED')
})

it('never returns one line of a multi-line secret', async () => {
const projected = await grepSecretFile(
`config start\n${keyBlock}\nconfig end`,
'FIXTURE KEY|KEYLINE'
)
for (const fragment of fragments(keyBlock)) expect(projected).not.toContain(fragment)
})
})

it('refuses an unknown file through the actual CLI text read without loading its bytes', async () => {
classify('unknown')
const result = await runCli(readFile)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/mothership/agent-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ async function executeBoundAgentCliRequest(
}
: {}),
...(context.chatId !== undefined ? { chatId: context.chatId } : {}),
resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry,
signal: context.signal,
},
invocation.flags
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/mothership/agent-cli/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Principal } from '@sim/auth/principal'
import type { CopilotChatDelegationContext } from '@/lib/mothership/auth/application-delegation'
import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
/**
* Sim's half of the mothership↔CLI translation layer: generic execution PRIMITIVES.
* The worker owns the grammar (what commands exist, how argv parses, pipes, help, the
Expand Down Expand Up @@ -36,6 +37,11 @@ export interface AgentCliRuntime {
/** The human the command acts as — reference resolution and grants scope to them. */
userId: string
chatId?: string
/**
* The invocation's secret provenance, which model egress redacts the result with. Egress
* matches whole secrets, so an engine that clips or splits text it read redacts it here first.
*/
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry

@cubic-dev-ai cubic-dev-ai Bot Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This optional field lets grep run without provenance, and redactForModel returns text unchanged when the registry is absent, so clipping can emit secret fragments. Fail closed when provenance is missing or require and populate the registry before this engine runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/mothership/agent-cli/types.ts, line 44:

<comment>This optional field lets grep run without provenance, and `redactForModel` returns text unchanged when the registry is absent, so clipping can emit secret fragments. Fail closed when provenance is missing or require and populate the registry before this engine runs.</comment>

<file context>
@@ -36,6 +37,11 @@ export interface AgentCliRuntime {
+   * The invocation's secret provenance, which model egress redacts the result with. Egress
+   * matches whole secrets, so an engine that clips or splits text it read redacts it here first.
+   */
+  resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
   signal?: AbortSignal
 }
</file context>
Fix with cubic

signal?: AbortSignal
}

Expand Down
Loading