Skip to content

Commit 115ec61

Browse files
author
DavidQ
committed
Add plugin security boundaries.
PR Details: - Restricts unsafe plugin behavior - Improves system safety
1 parent d0b65d4 commit 115ec61

7 files changed

Lines changed: 227 additions & 35 deletions

File tree

docs/dev/CODEX_COMMANDS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ MODEL: GPT-5.4
22
REASONING: medium
33

44
COMMAND:
5-
Implement plugin resource limits:
6-
- Enforce CPU/memory caps
7-
- Maintain stability
5+
Implement plugin security boundaries:
6+
- Restrict unsafe operations
7+
- Enforce access rules
88
- Update roadmap status only

docs/dev/COMMIT_COMMENT.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
Add plugin resource limits.
1+
Add plugin security boundaries.
22

33
PR Details:
4-
- Prevents resource abuse
4+
- Restricts unsafe plugin behavior
5+
- Improves system safety
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
[ ] Limits enforced
2-
[ ] System stable
3-
[ ] No degradation
1+
[ ] Restrictions enforced
2+
[ ] No unsafe access
3+
[ ] Normal plugins unaffected
44
[ ] Roadmap updated

docs/dev/roadmaps/MASTER_ROADMAP_HIGH_LEVEL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,7 @@
820820
### Track G — Extensibility Readiness
821821
- [x] validate plugin/extension patterns
822822
- [x] validate adding new systems is clean
823-
- [.] validate external integration points
823+
- [x] validate external integration points
824824
- [x] ensure future phases can build cleanly
825825

826826
### Track H — Final Stability Gate

docs/pr/BUILD_PR.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
# BUILD_PR_LEVEL_20_7_OVERLAY_PLUGIN_RESOURCE_LIMITS
1+
# BUILD_PR_LEVEL_20_8_OVERLAY_PLUGIN_SECURITY_BOUNDARIES
22

33
## Purpose
4-
Enforce resource limits on overlay plugins.
4+
Establish security boundaries for overlay plugins.
55

66
## Roadmap Improvement
7-
Prevents plugins from degrading system performance.
7+
Enhances system safety by restricting plugin capabilities.
88

99
## Scope
10-
- Define CPU/memory limits
11-
- Enforce limits
12-
- Validate behavior under limits
10+
- Define allowed plugin operations
11+
- Restrict unsafe access
12+
- Validate secure execution
1313

1414
## Test Steps
15-
1. Run heavy plugin
16-
2. Verify limits enforced
17-
3. Confirm system stability
15+
1. Attempt restricted operations
16+
2. Verify enforcement
17+
3. Confirm normal plugin behavior unaffected
1818

1919
## Expected
20-
- Limits enforced
21-
- No system degradation
20+
- Security rules enforced
21+
- No unsafe access

samples/phase-19/shared/overlay/createPhase19OverlayPluginRegistry.js

Lines changed: 160 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ const DEFAULT_RESOURCE_LIMITS = Object.freeze({
1919
maxHeapUsedBytes: 512 * 1024 * 1024,
2020
maxHeapDeltaBytes: 8 * 1024 * 1024,
2121
});
22+
const UNSAFE_CONTEXT_KEYS = Object.freeze([
23+
'process',
24+
'global',
25+
'globalThis',
26+
'window',
27+
'document',
28+
'Function',
29+
'eval',
30+
'require',
31+
'module',
32+
]);
33+
const RESERVED_PROTOTYPE_KEYS = Object.freeze(['__proto__', 'prototype', 'constructor']);
2234

2335
function normalizePluginId(pluginId) {
2436
return String(pluginId || '').trim();
@@ -32,6 +44,75 @@ function normalizeLimit(value, fallback) {
3244
return numeric;
3345
}
3446

47+
function isPlainObject(value) {
48+
if (!value || typeof value !== 'object') {
49+
return false;
50+
}
51+
const proto = Object.getPrototypeOf(value);
52+
return proto === Object.prototype || proto === null;
53+
}
54+
55+
function sanitizePluginContextValue(value, depth = 0) {
56+
if (depth > 6) {
57+
return null;
58+
}
59+
if (value === null) {
60+
return null;
61+
}
62+
const valueType = typeof value;
63+
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
64+
return value;
65+
}
66+
if (valueType === 'bigint') {
67+
return Number(value);
68+
}
69+
if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol') {
70+
return undefined;
71+
}
72+
73+
if (Array.isArray(value)) {
74+
const result = [];
75+
for (let i = 0; i < value.length; i += 1) {
76+
const sanitized = sanitizePluginContextValue(value[i], depth + 1);
77+
if (typeof sanitized === 'undefined') {
78+
continue;
79+
}
80+
result.push(sanitized);
81+
}
82+
return result;
83+
}
84+
85+
if (!isPlainObject(value)) {
86+
return undefined;
87+
}
88+
89+
const output = {};
90+
const keys = Object.keys(value);
91+
for (let i = 0; i < keys.length; i += 1) {
92+
const key = String(keys[i] || '').trim();
93+
if (!key) {
94+
continue;
95+
}
96+
if (UNSAFE_CONTEXT_KEYS.includes(key) || RESERVED_PROTOTYPE_KEYS.includes(key)) {
97+
continue;
98+
}
99+
const sanitized = sanitizePluginContextValue(value[key], depth + 1);
100+
if (typeof sanitized === 'undefined') {
101+
continue;
102+
}
103+
output[key] = sanitized;
104+
}
105+
return output;
106+
}
107+
108+
function sanitizePluginContext(context = {}) {
109+
const sanitized = sanitizePluginContextValue(context, 0);
110+
if (!sanitized || typeof sanitized !== 'object') {
111+
return {};
112+
}
113+
return sanitized;
114+
}
115+
35116
function normalizeResourceLimits(resourceLimits = {}) {
36117
return Object.freeze({
37118
maxHookDurationMs: normalizeLimit(resourceLimits?.maxHookDurationMs, DEFAULT_RESOURCE_LIMITS.maxHookDurationMs),
@@ -251,17 +332,70 @@ export default function createPhase19OverlayPluginRegistry({
251332
return pluginRecordMap.get(normalizedPluginId) ?? null;
252333
}
253334

254-
function getReadonlyRegistryView() {
255-
return createReadonlyFacade(api, [
256-
'getPlugin',
257-
'getPluginState',
258-
'getPluginExtensionId',
259-
'getPluginMetrics',
260-
'listPluginMetrics',
261-
'getPluginDiagnostics',
262-
'listPluginDiagnostics',
263-
'listPlugins',
264-
]);
335+
function getReadonlyRegistryView(pluginId = '') {
336+
const normalizedPluginId = normalizePluginId(pluginId);
337+
return Object.freeze({
338+
getPlugin(requestedPluginId = normalizedPluginId) {
339+
const requestedId = normalizePluginId(requestedPluginId);
340+
if (!requestedId || requestedId !== normalizedPluginId) {
341+
return null;
342+
}
343+
return api.getPlugin(requestedId);
344+
},
345+
getPluginState(requestedPluginId = normalizedPluginId) {
346+
const requestedId = normalizePluginId(requestedPluginId);
347+
if (!requestedId || requestedId !== normalizedPluginId) {
348+
return '';
349+
}
350+
return api.getPluginState(requestedId);
351+
},
352+
getPluginExtensionId(requestedPluginId = normalizedPluginId) {
353+
const requestedId = normalizePluginId(requestedPluginId);
354+
if (!requestedId || requestedId !== normalizedPluginId) {
355+
return '';
356+
}
357+
return api.getPluginExtensionId(requestedId);
358+
},
359+
getPluginMetrics(requestedPluginId = normalizedPluginId) {
360+
const requestedId = normalizePluginId(requestedPluginId);
361+
if (!requestedId || requestedId !== normalizedPluginId) {
362+
return null;
363+
}
364+
return api.getPluginMetrics(requestedId);
365+
},
366+
getPluginDiagnostics(requestedPluginId = normalizedPluginId) {
367+
const requestedId = normalizePluginId(requestedPluginId);
368+
if (!requestedId || requestedId !== normalizedPluginId) {
369+
return null;
370+
}
371+
return api.getPluginDiagnostics(requestedId);
372+
},
373+
listPlugins() {
374+
const plugin = api.getPlugin(normalizedPluginId);
375+
if (!plugin) {
376+
return Object.freeze([]);
377+
}
378+
return Object.freeze([{
379+
id: normalizedPluginId,
380+
state: api.getPluginState(normalizedPluginId),
381+
extensionId: api.getPluginExtensionId(normalizedPluginId),
382+
}]);
383+
},
384+
listPluginMetrics() {
385+
const metrics = api.getPluginMetrics(normalizedPluginId);
386+
if (!metrics) {
387+
return Object.freeze([]);
388+
}
389+
return Object.freeze([{ pluginId: normalizedPluginId, metrics }]);
390+
},
391+
listPluginDiagnostics() {
392+
const diagnostics = api.getPluginDiagnostics(normalizedPluginId);
393+
if (!diagnostics) {
394+
return Object.freeze([]);
395+
}
396+
return Object.freeze([diagnostics]);
397+
},
398+
});
265399
}
266400

267401
function getReadonlyFrameworkView() {
@@ -454,13 +588,19 @@ export default function createPhase19OverlayPluginRegistry({
454588
try {
455589
const previousHookPluginId = activeHookPluginId;
456590
activeHookPluginId = record.plugin.id;
591+
const safeContext = sanitizePluginContext(context);
457592
const lifecycleContext = deepFreezeValue({
458-
...context,
593+
...safeContext,
459594
phase,
460595
pluginId: record.plugin.id,
461596
extensionId: record.extension.id,
462-
registry: getReadonlyRegistryView(),
597+
registry: getReadonlyRegistryView(record.plugin.id),
463598
expansionFramework: getReadonlyFrameworkView(),
599+
security: {
600+
mode: 'isolated',
601+
scopedRegistryAccess: true,
602+
unsafeContextKeysRemoved: UNSAFE_CONTEXT_KEYS,
603+
},
464604
});
465605
try {
466606
hook(lifecycleContext);
@@ -697,10 +837,15 @@ export default function createPhase19OverlayPluginRegistry({
697837
normalizedPlugin,
698838
normalizedPlugin.createOverlayExtension
699839
? normalizedPlugin.createOverlayExtension({
840+
...sanitizePluginContext(context),
700841
pluginId,
701-
registry: getReadonlyRegistryView(),
842+
registry: getReadonlyRegistryView(pluginId),
702843
expansionFramework: getReadonlyFrameworkView(),
703-
...context,
844+
security: {
845+
mode: 'isolated',
846+
scopedRegistryAccess: true,
847+
unsafeContextKeysRemoved: UNSAFE_CONTEXT_KEYS,
848+
},
704849
})
705850
: normalizedPlugin.extension
706851
);

tests/runtime/Phase19OverlayPluginRegistry.test.mjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,17 @@ function assertPluginRegistrationAndRuntimeCompatibility() {
3939
createOverlayExtension(context) {
4040
accessSurface.registryRegisterPluginType = typeof context?.registry?.registerPlugin;
4141
accessSurface.registryGetPluginStateType = typeof context?.registry?.getPluginState;
42+
accessSurface.registryOtherStateFromCreate = context?.registry?.getPluginState?.('phase19.other');
43+
accessSurface.createListPluginsLength = Array.isArray(context?.registry?.listPlugins?.())
44+
? context.registry.listPlugins().length
45+
: -1;
4246
accessSurface.frameworkRegisterExtensionType = typeof context?.expansionFramework?.registerExtension;
4347
accessSurface.frameworkListExtensionIdsType = typeof context?.expansionFramework?.listExtensionIds;
48+
accessSurface.contextSafeToken = context?.safeToken;
49+
accessSurface.contextUnsafeProcessType = typeof context?.process;
50+
accessSurface.contextUnsafeWindowType = typeof context?.window;
51+
accessSurface.contextNestedFnType = typeof context?.nested?.fn;
52+
accessSurface.securityModeFromCreate = context?.security?.mode;
4453
return definePhase19OverlayExtension({
4554
id: 'phase19.runtime.plugin.overlay',
4655
overlays: [
@@ -63,12 +72,49 @@ function assertPluginRegistrationAndRuntimeCompatibility() {
6372
],
6473
});
6574
},
75+
init(context) {
76+
accessSurface.securityModeFromInit = context?.security?.mode;
77+
accessSurface.scopedRegistryAccess = context?.security?.scopedRegistryAccess;
78+
accessSurface.registryOtherStateFromInit = context?.registry?.getPluginState?.('phase19.other');
79+
accessSurface.initListPlugins = context?.registry?.listPlugins?.();
80+
accessSurface.initListPluginMetrics = context?.registry?.listPluginMetrics?.();
81+
accessSurface.initUnsafeDocumentType = typeof context?.document;
82+
},
83+
}, {
84+
context: {
85+
safeToken: 'ok-token',
86+
process: { env: 'unsafe' },
87+
window: { location: 'unsafe' },
88+
nested: {
89+
keep: true,
90+
fn() {},
91+
},
92+
document: {
93+
body: {},
94+
},
95+
},
6696
});
6797

6898
assert.equal(accessSurface.registryRegisterPluginType, 'undefined', 'Plugin creation context must not expose mutating registry methods.');
6999
assert.equal(accessSurface.registryGetPluginStateType, 'function', 'Plugin creation context should expose read-only registry introspection.');
100+
assert.equal(accessSurface.registryOtherStateFromCreate, '', 'Scoped registry view should block access to other plugin state during creation.');
101+
assert.equal(accessSurface.createListPluginsLength, 0, 'Scoped registry view should not expose other plugins during creation context.');
70102
assert.equal(accessSurface.frameworkRegisterExtensionType, 'undefined', 'Plugin creation context must not expose mutating framework methods.');
71103
assert.equal(accessSurface.frameworkListExtensionIdsType, 'function', 'Plugin creation context should expose read-only framework introspection.');
104+
assert.equal(accessSurface.contextSafeToken, 'ok-token', 'Sanitized context should preserve safe primitive keys.');
105+
assert.equal(accessSurface.contextUnsafeProcessType, 'undefined', 'Sanitized context should strip unsafe process key.');
106+
assert.equal(accessSurface.contextUnsafeWindowType, 'undefined', 'Sanitized context should strip unsafe window key.');
107+
assert.equal(accessSurface.contextNestedFnType, 'undefined', 'Sanitized context should strip nested function values.');
108+
assert.equal(accessSurface.securityModeFromCreate, 'isolated', 'Security context should be available during extension creation.');
109+
assert.equal(accessSurface.securityModeFromInit, 'isolated', 'Security context should be available during lifecycle hooks.');
110+
assert.equal(accessSurface.scopedRegistryAccess, true, 'Security context should mark scoped registry access.');
111+
assert.equal(accessSurface.registryOtherStateFromInit, '', 'Scoped registry view should block access to other plugin state during lifecycle hooks.');
112+
assert.equal(Array.isArray(accessSurface.initListPlugins), true, 'Scoped registry list should be present during lifecycle hooks.');
113+
assert.equal(accessSurface.initListPlugins.length, 1, 'Scoped registry list should include only current plugin entry.');
114+
assert.equal(accessSurface.initListPlugins[0]?.id, 'phase19.runtime.plugin', 'Scoped registry list should only include current plugin id.');
115+
assert.equal(Array.isArray(accessSurface.initListPluginMetrics), true, 'Scoped registry metrics list should be available.');
116+
assert.equal(accessSurface.initListPluginMetrics.length, 1, 'Scoped registry metrics should include only current plugin.');
117+
assert.equal(accessSurface.initUnsafeDocumentType, 'undefined', 'Lifecycle context should strip unsafe document key.');
72118

73119
assert.deepEqual(
74120
result,

0 commit comments

Comments
 (0)