Skip to content

Commit 0e71127

Browse files
icecrasher321claude
andcommitted
fix(secrets): an update reads before it stores, and a del target may be parenthesized
Review round 5. The first of these is a regression from round 4. - javascript.ts: reusing isWriteIdentifier to answer "is this a read" was wrong. That predicate answers the rewriter's question — is this a target the substitution must refuse — so it treats every assignment operator alike, which is correct there and wrong here: `+=`, `||=`, `??=`, `++` and `--` all load the current value before storing, so they are genuine reads and were silently losing their masking. Only a plain `=` stores without reading. Replaced with a purpose-named predicate, and isWriteIdentifier's parameter is narrowed back to ts.Identifier now that nothing else needs it widened. A test committed last round asserted the wrong behaviour for `+=`; it has been corrected rather than left to pin the bug. - python.ts: `del (environmentVariables['K'])` slipped past a check that looked only at the characters immediately before the match. It now isolates the enclosing logical line and tests whether that is a del statement, which also covers `del((x))`, `del(x)`, `del a, x`, and a del after a semicolon. 12 tests added or corrected; 10 fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3998ac6 commit 0e71127

3 files changed

Lines changed: 88 additions & 12 deletions

File tree

apps/sim/lib/execution/code-placeholders/compiler.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1322,7 +1322,6 @@ describe('writing a configured name is not reading it', () => {
13221322
it.each([
13231323
['javascript property assignment', "environmentVariables.API_KEY = 'x'"],
13241324
['javascript subscript assignment', "environmentVariables['API_KEY'] = 'x'"],
1325-
['javascript compound assignment', "environmentVariables.API_KEY += 'x'"],
13261325
['javascript delete', 'delete environmentVariables.API_KEY'],
13271326
['javascript delete subscript', "delete environmentVariables['API_KEY']"],
13281327
])('%s', async (_label, code) => {
@@ -1336,6 +1335,50 @@ describe('writing a configured name is not reading it', () => {
13361335
expect(await directReadNames(code, CodeLanguage.Python)).toEqual([])
13371336
})
13381337

1338+
/**
1339+
* A compound, logical, or increment update loads the current value before storing, so it is
1340+
* a read of the mounted secret and has to keep its masking. Only a plain `=` stores without
1341+
* reading.
1342+
*/
1343+
it.each([
1344+
['compound assignment', "environmentVariables.API_KEY += 'x'"],
1345+
['logical assignment', "environmentVariables.API_KEY ||= 'x'"],
1346+
['nullish assignment', "environmentVariables.API_KEY ??= 'x'"],
1347+
['subscript compound assignment', "environmentVariables['API_KEY'] += 'x'"],
1348+
['postfix increment', 'environmentVariables.API_KEY++'],
1349+
['prefix increment', '++environmentVariables.API_KEY'],
1350+
])('javascript reads through an update: %s', async (_label, code) => {
1351+
expect(await directReadNames(code, CodeLanguage.JavaScript)).toEqual(['API_KEY'])
1352+
})
1353+
1354+
/** Python's augmented assignment reads first too. */
1355+
it('python reads through an augmented assignment', async () => {
1356+
expect(
1357+
await directReadNames("environmentVariables['API_KEY'] += 'x'", CodeLanguage.Python)
1358+
).toEqual(['API_KEY'])
1359+
})
1360+
1361+
/** Greptile's case: `del` targets may be parenthesized or listed. */
1362+
it.each([
1363+
['parenthesized', "del (environmentVariables['API_KEY'])"],
1364+
['double parenthesized', "del ((environmentVariables['API_KEY']))"],
1365+
['no space before paren', "del(environmentVariables['API_KEY'])"],
1366+
['multi-target', "del other, environmentVariables['API_KEY']"],
1367+
['after a semicolon', "x = 1; del environmentVariables['API_KEY']"],
1368+
])('python del target: %s', async (_label, code) => {
1369+
expect(await directReadNames(code, CodeLanguage.Python)).toEqual([])
1370+
})
1371+
1372+
/** `delete` on a line of its own must not disable a real read elsewhere. */
1373+
it('python still reports a read on another line', async () => {
1374+
expect(
1375+
await directReadNames(
1376+
"del environmentVariables['API_KEY']\nk = environmentVariables['API_KEY']",
1377+
CodeLanguage.Python
1378+
)
1379+
).toEqual(['API_KEY'])
1380+
})
1381+
13391382
/** Reading the same key elsewhere is still a use, even if another line writes it. */
13401383
it('javascript still reports a read alongside a write', async () => {
13411384
expect(

apps/sim/lib/execution/code-placeholders/javascript.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,34 @@ const ENVIRONMENT_VARIABLES_IDENTIFIER = 'environmentVariables'
4141
* {@link CodePlaceholderCompilationContext.recordDirectEnvironmentRead}.
4242
*/
4343
/**
44-
* Whether this member access is being written or deleted rather than read.
44+
* Whether this member access is written *without* being read.
4545
*
46-
* `environmentVariables.API_KEY = 'x'` and `delete environmentVariables.API_KEY` both touch the
47-
* name without ever reading the mounted value, so recording either would put a use in the trail
48-
* that never happened. `isWriteIdentifier` already answers the write half for the placeholder
49-
* rewriter; `delete` is asked here because only a read detector cares about it.
46+
* Only a plain `=` and `delete` qualify. A compound assignment (`+=`), a logical assignment
47+
* (`||=`, `&&=`, `??=`), and `++`/`--` all load the current value before storing, so they are
48+
* genuine reads of the mounted secret and have to keep their masking.
49+
*
50+
* This deliberately does not reuse `isWriteIdentifier`. That predicate answers the rewriter's
51+
* question — is this a target the substitution must refuse — and so treats every assignment
52+
* operator alike, which is correct there and wrong here. Two different questions; conflating
53+
* them silently dropped `+=` from masking.
54+
*
55+
* A member reached through a destructuring assignment (`({ k: environmentVariables.K } = o)`)
56+
* is not recognized and is reported as a read. That is the mild direction: an extra usage row
57+
* rather than an unmasked value.
5058
*/
51-
function writesEnvironmentMember(node: ts.Node): boolean {
52-
return ts.isDeleteExpression(node.parent) || isWriteIdentifier(node)
59+
function writesWithoutReading(node: ts.Node): boolean {
60+
const parent = node.parent
61+
if (!parent) return false
62+
if (ts.isDeleteExpression(parent)) return true
63+
return (
64+
ts.isBinaryExpression(parent) &&
65+
parent.left === node &&
66+
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
67+
)
5368
}
5469

5570
function directEnvironmentRead(node: ts.Node): DirectEnvironmentRead | undefined {
56-
if (writesEnvironmentMember(node)) return undefined
71+
if (writesWithoutReading(node)) return undefined
5772
if (ts.isPropertyAccessExpression(node)) {
5873
if (!ts.isIdentifier(node.expression)) return undefined
5974
if (node.expression.text !== ENVIRONMENT_VARIABLES_IDENTIFIER) return undefined
@@ -450,7 +465,7 @@ function isDeclarationIdentifier(node: ts.Identifier): boolean {
450465
)
451466
}
452467

453-
function isWriteIdentifier(node: ts.Node): boolean {
468+
function isWriteIdentifier(node: ts.Identifier): boolean {
454469
let current: ts.Node = node
455470
let targetPosition = true
456471
for (let parent = current.parent; parent; current = parent, parent = parent.parent) {

apps/sim/lib/execution/code-placeholders/python.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,28 @@ const PYTHON_ENVIRONMENT_IDENTIFIER = /environmentVariables/g
577577

578578
/**
579579
* A subscript that is being assigned to rather than read: `environmentVariables['K'] = v`.
580-
* The trailing `=` must not be `==`, `!=`, `<=`, `>=`, or `:=`, none of which write.
580+
* The trailing `=` must not be `==`, `!=`, `<=`, `>=`, or `:=`, none of which write. An
581+
* augmented assignment (`+=`) is absent on purpose: it loads the current value first, so it
582+
* is a read.
581583
*/
582584
const PYTHON_SUBSCRIPT_WRITE = /^\s*=(?!=)/
583585

586+
/** A `del` statement, once the enclosing logical line has been isolated. */
587+
const PYTHON_DEL_STATEMENT = /^del[\s(]/
588+
589+
/**
590+
* Whether the mention sits inside a `del` statement, which removes the key without reading it.
591+
*
592+
* Tested against the whole statement rather than the few characters before the match, so the
593+
* parenthesized and multi-target forms — `del (environmentVariables['K'])` and
594+
* `del other, environmentVariables['K']` — are recognized alongside the plain one.
595+
*/
596+
function isDeleteTarget(code: string, offset: number): boolean {
597+
const before = code.slice(0, offset)
598+
const statementStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';')) + 1
599+
return PYTHON_DEL_STATEMENT.test(before.slice(statementStart).trimStart())
600+
}
601+
584602
/**
585603
* The only two shapes this detector can attribute: a literal subscript or `.get()`.
586604
*
@@ -650,7 +668,7 @@ function recordPythonDirectEnvironmentReads(
650668
* without reading the mounted value, so neither is a use of the secret.
651669
*/
652670
if (PYTHON_SUBSCRIPT_WRITE.test(code.slice(candidate.index + candidate[0].length))) continue
653-
if (/(^|[\s;:])del\s+$/.test(code.slice(0, candidate.index))) continue
671+
if (isDeleteTarget(code, candidate.index)) continue
654672
const name = candidate[2] ?? candidate[4]
655673
if (name) context.recordDirectEnvironmentRead(name, candidate.index)
656674
}

0 commit comments

Comments
 (0)