Skip to content

Commit f5f4c92

Browse files
committed
fix: close snapshot cutover alias bypasses
1 parent f4abac9 commit f5f4c92

2 files changed

Lines changed: 220 additions & 9 deletions

File tree

scripts/layering/runtime-command-cutover-snapshot.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,54 @@ test('R32 snapshot rejects a provider-mode branch in daemon admission', () => {
5252
],
5353
);
5454
});
55+
56+
test('R32 snapshot rejects device-owner policy through chained aliases', () => {
57+
assert.deepEqual(
58+
violationsFor(`
59+
const identity = device;
60+
const owner = identity;
61+
if (owner.platform === 'apple') return plan;
62+
`),
63+
[
64+
'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts',
65+
],
66+
);
67+
});
68+
69+
test('R32 snapshot rejects destructured provider-owner policy', () => {
70+
assert.deepEqual(
71+
violationsFor(`
72+
const { providerMode } = facts.device;
73+
if (providerMode === 'provider-runtime') return plan;
74+
`),
75+
[
76+
'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts',
77+
],
78+
);
79+
});
80+
81+
test('R32 snapshot rejects nested device-leaf destructuring from admission params', () => {
82+
assert.deepEqual(
83+
violationsFor(`
84+
const { device: selectedDevice } = params;
85+
const { kind } = selectedDevice;
86+
if (kind === 'simulator') return plan;
87+
`),
88+
[
89+
'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts',
90+
],
91+
);
92+
});
93+
94+
test('R32 snapshot rejects device-owner policy through an assigned alias', () => {
95+
assert.deepEqual(
96+
violationsFor(`
97+
let identity;
98+
identity = device;
99+
if (identity.kind === 'simulator') return plan;
100+
`),
101+
[
102+
'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts',
103+
],
104+
);
105+
});

scripts/layering/runtime-command-cutover-snapshot.ts

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parseSync } from 'oxc-parser';
2-
import { memberPath, visitAst } from './cutover-policy-ast.ts';
2+
import { propertyName, visitAst } from './cutover-policy-ast.ts';
33
import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts';
44
import type { UnruledViolation } from './runtime-command-cutover-model.ts';
55

@@ -34,23 +34,35 @@ export function snapshotPlatformPolicyBranchViolations(
3434
];
3535
}
3636

37+
const aliases = snapshotAdmissionAliases(admission);
3738
const violations: UnruledViolation[] = [];
3839
const seenLines = new Set<number>();
3940
let readsRequiredOperations = false;
4041
let readsOperationFacts = false;
4142
let admitsAvailableFacts = false;
4243
visitAst(admission, (node) => {
43-
const path = memberPath(node);
44+
const binding = aliasBinding(node);
45+
if (binding !== undefined) {
46+
for (const path of boundPatternPaths(
47+
binding.pattern,
48+
initializerPath(binding.value, aliases),
49+
)) {
50+
if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node);
51+
}
52+
}
53+
if (node.type !== 'MemberExpression' && node.type !== 'ChainExpression') return;
54+
const path = canonicalMemberPath(node, aliases);
4455
if (path === undefined) return;
4556
if (samePath(path, ['plan', 'use', 'required'])) readsRequiredOperations = true;
4657
if (samePath(path, ['facts', 'operations'])) readsOperationFacts = true;
47-
if (samePath(path, ['fact', 'available'])) admitsAvailableFacts = true;
58+
if (path[0] === 'facts' && path[1] === 'operations' && path[path.length - 1] === 'available') {
59+
admitsAvailableFacts = true;
60+
}
4861

49-
const readsDeviceLeaf =
50-
(path[0] === 'device' && path.length > 1) ||
51-
(path[0] === 'params' && path[1] === 'device' && path.length > 2);
52-
const readsOwnerIdentity = path[0] === 'facts' && path[1] === 'device' && path.length > 2;
53-
if (!readsDeviceLeaf && !readsOwnerIdentity) return;
62+
if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node);
63+
});
64+
65+
function addOwnerIdentityViolation(node: AstNode): void {
5466
const line = lineOf(source, node);
5567
if (seenLines.has(line)) return;
5668
seenLines.add(line);
@@ -59,7 +71,7 @@ export function snapshotPlatformPolicyBranchViolations(
5971
line,
6072
message: 'snapshot admission reads device-owner identity instead of selected operation facts',
6173
});
62-
});
74+
}
6375

6476
if (countNamedCalls(admission, 'resolveSnapshotRuntimePlan') !== 1) {
6577
violations.push({
@@ -78,6 +90,154 @@ export function snapshotPlatformPolicyBranchViolations(
7890
return violations;
7991
}
8092

93+
function snapshotAdmissionAliases(admission: AstNode): ReadonlyMap<string, readonly string[]> {
94+
const aliases = new Map<string, readonly string[]>();
95+
const params = admission.params;
96+
if (Array.isArray(params)) {
97+
const canonicalParams = [['params'], ['device'], ['session']] as const;
98+
for (const [index, parameter] of params.entries()) {
99+
if (index >= canonicalParams.length || !isIdentifier(parameter)) continue;
100+
aliases.set(parameter.name, canonicalParams[index]);
101+
}
102+
}
103+
104+
const bindings: AstNode[] = [];
105+
visitAst(admission, (node) => {
106+
if (aliasBinding(node) !== undefined) bindings.push(node);
107+
});
108+
// Every acyclic alias chain stabilizes within this bound; malformed cycles cannot hang the gate.
109+
for (let pass = 0; pass <= bindings.length; pass += 1) {
110+
let changed = false;
111+
for (const node of bindings) {
112+
const binding = aliasBinding(node)!;
113+
const path = initializerPath(binding.value, aliases);
114+
for (const [name, aliasPath] of boundPatternAliases(binding.pattern, path)) {
115+
if (samePath(aliases.get(name) ?? [], aliasPath)) continue;
116+
aliases.set(name, aliasPath);
117+
changed = true;
118+
}
119+
}
120+
if (!changed) break;
121+
}
122+
return aliases;
123+
}
124+
125+
function aliasBinding(node: AstNode): Readonly<{ pattern: unknown; value: unknown }> | undefined {
126+
if (node.type === 'VariableDeclarator') return { pattern: node.id, value: node.init };
127+
if (node.type === 'AssignmentExpression' && node.operator === '=') {
128+
return { pattern: node.left, value: node.right };
129+
}
130+
return undefined;
131+
}
132+
133+
function initializerPath(
134+
node: unknown,
135+
aliases: ReadonlyMap<string, readonly string[]>,
136+
): readonly string[] | undefined {
137+
const value = unwrapExpression(node);
138+
if (value === undefined) return undefined;
139+
if (value.type === 'CallExpression') {
140+
if (containsNamedCall(value, 'requireRuntimeFacts')) return ['facts'];
141+
if (containsNamedCall(value, 'resolveSnapshotRuntimePlan')) return ['plan'];
142+
}
143+
return canonicalMemberPath(value, aliases);
144+
}
145+
146+
function canonicalMemberPath(
147+
node: unknown,
148+
aliases: ReadonlyMap<string, readonly string[]>,
149+
): readonly string[] | undefined {
150+
const value = unwrapExpression(node);
151+
if (value === undefined) return undefined;
152+
if (value.type === 'Identifier') {
153+
const name = typeof value.name === 'string' ? value.name : undefined;
154+
return name === undefined ? undefined : (aliases.get(name) ?? [name]);
155+
}
156+
if (value.type !== 'MemberExpression') return undefined;
157+
const object = canonicalMemberPath(value.object, aliases);
158+
const name = propertyName(value.property);
159+
return object === undefined || name === undefined
160+
? undefined
161+
: canonicalizePath([...object, name]);
162+
}
163+
164+
function canonicalizePath(path: readonly string[]): readonly string[] {
165+
if (path[0] === 'params' && path[1] === 'device') return ['device', ...path.slice(2)];
166+
if (path[0] === 'params' && path[1] === 'session') return ['session', ...path.slice(2)];
167+
if (path[0] === 'session' && path[1] === 'device') return ['device', ...path.slice(2)];
168+
return path;
169+
}
170+
171+
function boundPatternAliases(
172+
pattern: unknown,
173+
path: readonly string[] | undefined,
174+
): ReadonlyArray<readonly [string, readonly string[]]> {
175+
if (path === undefined || pattern === null || typeof pattern !== 'object') return [];
176+
const value = pattern as AstNode;
177+
if (value.type === 'Identifier' && typeof value.name === 'string') {
178+
return [[value.name, canonicalizePath(path)]];
179+
}
180+
if (value.type === 'AssignmentPattern') return boundPatternAliases(value.left, path);
181+
if (value.type !== 'ObjectPattern' || !Array.isArray(value.properties)) return [];
182+
const aliases: Array<readonly [string, readonly string[]]> = [];
183+
for (const property of value.properties) {
184+
if (property === null || typeof property !== 'object') continue;
185+
const entry = property as AstNode;
186+
if (entry.type !== 'Property') continue;
187+
const name = propertyName(entry.key);
188+
if (name === undefined) continue;
189+
aliases.push(...boundPatternAliases(entry.value, canonicalizePath([...path, name])));
190+
}
191+
return aliases;
192+
}
193+
194+
function boundPatternPaths(
195+
pattern: unknown,
196+
path: readonly string[] | undefined,
197+
): readonly (readonly string[])[] {
198+
return boundPatternAliases(pattern, path).map(([, aliasPath]) => aliasPath);
199+
}
200+
201+
function readsDeviceOwnerIdentity(path: readonly string[]): boolean {
202+
return (
203+
(path[0] === 'device' && path.length > 1) ||
204+
(path[0] === 'facts' && path[1] === 'device' && path.length > 2)
205+
);
206+
}
207+
208+
function containsNamedCall(node: unknown, name: string): boolean {
209+
let found = false;
210+
visitAst(node, (candidate) => {
211+
if (candidate.type !== 'CallExpression') return;
212+
const callee = unwrapExpression(candidate.callee);
213+
if (callee?.type === 'Identifier' && callee.name === name) found = true;
214+
});
215+
return found;
216+
}
217+
218+
function unwrapExpression(node: unknown): AstNode | undefined {
219+
if (node === null || typeof node !== 'object') return undefined;
220+
const value = node as AstNode;
221+
if (
222+
value.type === 'AwaitExpression' ||
223+
value.type === 'ChainExpression' ||
224+
value.type === 'TSAsExpression' ||
225+
value.type === 'TSNonNullExpression'
226+
) {
227+
return unwrapExpression(value.expression);
228+
}
229+
return value;
230+
}
231+
232+
function isIdentifier(node: unknown): node is AstNode & { name: string } {
233+
return (
234+
node !== null &&
235+
typeof node === 'object' &&
236+
(node as AstNode).type === 'Identifier' &&
237+
typeof (node as AstNode).name === 'string'
238+
);
239+
}
240+
81241
function samePath(actual: readonly string[], expected: readonly string[]): boolean {
82242
return (
83243
actual.length === expected.length && actual.every((part, index) => part === expected[index])

0 commit comments

Comments
 (0)