Skip to content

Commit 5e93b5c

Browse files
committed
Add Restricted Mode for untrusted projects
PsySH now prompts before loading project-local config (.psysh.php), local PsySH binaries, or Composer autoloads from untrusted projects. This prevents a potential class of abuse where running PsySH inside an untrusted folder could execute arbitrary code. Trusted project roots are persisted in trusted_projects.json. Configure with 'trustProject' => 'prompt' | 'always' | 'never', or use the `--trust-project` / `--no-trust-project` CLI flags, or the `PSYSH_TRUST_PROJECT` env var.
1 parent 9bfc28d commit 5e93b5c

11 files changed

Lines changed: 1508 additions & 17 deletions

bin/psysh

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
call_user_func(function () {
1616
$cwd = null;
1717
$cwdFromArg = false;
18+
$forceTrust = false;
19+
$forceUntrust = false;
1820

1921
// Find the cwd arg (if present)
2022
$argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : array();
@@ -34,6 +36,14 @@ call_user_func(function () {
3436
$cwdFromArg = true;
3537
continue;
3638
}
39+
40+
if ($arg === '--trust-project') {
41+
$forceTrust = true;
42+
$forceUntrust = false;
43+
} elseif ($arg === '--no-trust-project') {
44+
$forceUntrust = true;
45+
$forceTrust = false;
46+
}
3747
}
3848

3949
if ($cwdFromArg) {
@@ -72,6 +82,131 @@ call_user_func(function () {
7282
$argv = $filtered;
7383
}
7484

85+
if (isset($_SERVER['PSYSH_TRUST_PROJECT']) && $_SERVER['PSYSH_TRUST_PROJECT'] !== '') {
86+
$mode = strtolower(trim($_SERVER['PSYSH_TRUST_PROJECT']));
87+
if (in_array($mode, array('true', '1'))) {
88+
$forceTrust = true;
89+
$forceUntrust = false;
90+
} elseif (in_array($mode, array('false', '0'))) {
91+
$forceUntrust = true;
92+
$forceTrust = false;
93+
} else {
94+
fwrite(STDERR, 'Invalid PSYSH_TRUST_PROJECT value: ' . $_SERVER['PSYSH_TRUST_PROJECT'] . '. Expected: true, 1, false, or 0.' . PHP_EOL);
95+
exit(1);
96+
}
97+
}
98+
99+
// Pass trust decision via env var and strip CLI flags. This allows a local
100+
// psysh version to read the trust state while avoiding errors on older
101+
// versions that don't understand --trust-project flags.
102+
if ($forceTrust) {
103+
$_SERVER['PSYSH_TRUST_PROJECT'] = 'true';
104+
$_ENV['PSYSH_TRUST_PROJECT'] = 'true';
105+
putenv('PSYSH_TRUST_PROJECT=true');
106+
} elseif ($forceUntrust) {
107+
$_SERVER['PSYSH_TRUST_PROJECT'] = 'false';
108+
$_ENV['PSYSH_TRUST_PROJECT'] = 'false';
109+
putenv('PSYSH_TRUST_PROJECT=false');
110+
}
111+
112+
if ($forceTrust || $forceUntrust) {
113+
$filtered = array();
114+
foreach ($argv as $arg) {
115+
if ($arg === '--trust-project' || $arg === '--no-trust-project') {
116+
continue;
117+
}
118+
$filtered[] = $arg;
119+
}
120+
$_SERVER['argv'] = $filtered;
121+
$_SERVER['argc'] = count($filtered);
122+
$argv = $filtered;
123+
}
124+
125+
$trustedRoots = array();
126+
if (!$forceTrust) {
127+
// Find the current config directory (matching ConfigPaths logic)
128+
$currentConfigDir = null;
129+
$fallbackConfigDir = null;
130+
131+
// Windows: %APPDATA%/PsySH takes priority
132+
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
133+
if (isset($_SERVER['APPDATA']) && $_SERVER['APPDATA']) {
134+
$dir = str_replace('\\', '/', $_SERVER['APPDATA']).'/PsySH';
135+
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
136+
if (@is_dir($dir)) {
137+
$currentConfigDir = $dir;
138+
}
139+
}
140+
}
141+
142+
// XDG_CONFIG_HOME/psysh
143+
if ($currentConfigDir === null && isset($_SERVER['XDG_CONFIG_HOME']) && $_SERVER['XDG_CONFIG_HOME']) {
144+
$dir = rtrim(str_replace('\\', '/', $_SERVER['XDG_CONFIG_HOME']), '/').'/psysh';
145+
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
146+
if (@is_dir($dir)) {
147+
$currentConfigDir = $dir;
148+
}
149+
}
150+
151+
// HOME/.config/psysh (default XDG location)
152+
if ($currentConfigDir === null && isset($_SERVER['HOME']) && $_SERVER['HOME']) {
153+
$home = rtrim(str_replace('\\', '/', $_SERVER['HOME']), '/');
154+
155+
$dir = $home.'/.config/psysh';
156+
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
157+
if (@is_dir($dir)) {
158+
$currentConfigDir = $dir;
159+
}
160+
161+
// legacy
162+
if ($currentConfigDir === null) {
163+
$dir = $home.'/.psysh';
164+
if (@is_dir($dir)) {
165+
$currentConfigDir = $dir;
166+
}
167+
}
168+
}
169+
170+
// Windows: HOMEDRIVE/HOMEPATH fallback
171+
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
172+
if (isset($_SERVER['HOMEDRIVE']) && isset($_SERVER['HOMEPATH']) && $_SERVER['HOMEDRIVE'] && $_SERVER['HOMEPATH']) {
173+
$dir = rtrim(str_replace('\\', '/', $_SERVER['HOMEDRIVE'].'/'.$_SERVER['HOMEPATH']), '/').'/.psysh';
174+
if (@is_dir($dir)) {
175+
$currentConfigDir = $dir;
176+
}
177+
}
178+
}
179+
180+
// Fall back to the first candidate directory if none exist yet
181+
if ($currentConfigDir === null) {
182+
$currentConfigDir = $fallbackConfigDir;
183+
}
184+
185+
if ($currentConfigDir !== null) {
186+
$trustFile = $currentConfigDir.'/trusted_projects.json';
187+
if (is_file($trustFile)) {
188+
$contents = file_get_contents($trustFile);
189+
if ($contents !== false && $contents !== '') {
190+
$data = json_decode($contents, true);
191+
if (is_array($data)) {
192+
foreach ($data as $dir) {
193+
if (!is_string($dir) || $dir === '') {
194+
continue;
195+
}
196+
197+
$real = realpath($dir);
198+
if ($real !== false) {
199+
$dir = $real;
200+
}
201+
202+
$trustedRoots[] = str_replace('\\', '/', $dir);
203+
}
204+
}
205+
}
206+
}
207+
}
208+
}
209+
75210
$chunks = explode('/', $cwd);
76211
while (!empty($chunks)) {
77212
$path = implode('/', $chunks);
@@ -86,6 +221,17 @@ call_user_func(function () {
86221
if (isset($cfg['name']) && $cfg['name'] === 'psy/psysh') {
87222
// We're inside the psysh project. Let's use the local Composer autoload.
88223
if (is_file($path . '/vendor/autoload.php')) {
224+
$realPath = realpath($path);
225+
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
226+
227+
if (!$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
228+
fwrite(STDERR, 'Skipping local PsySH at ' . $prettyPath . ' (project is untrusted). Re-run with --trust-project to allow.' . PHP_EOL);
229+
$_SERVER['PSYSH_UNTRUSTED_PROJECT'] = $realPath;
230+
$_ENV['PSYSH_UNTRUSTED_PROJECT'] = $realPath;
231+
putenv('PSYSH_UNTRUSTED_PROJECT='.$realPath);
232+
return;
233+
}
234+
89235
if (realpath($path) !== realpath(__DIR__ . '/..')) {
90236
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
91237
}
@@ -101,10 +247,22 @@ call_user_func(function () {
101247
// Or a composer.lock
102248
if (is_file($path . '/composer.lock')) {
103249
if ($cfg = json_decode(file_get_contents($path . '/composer.lock'), true)) {
104-
foreach (array_merge($cfg['packages'], $cfg['packages-dev']) as $pkg) {
250+
$packages = array_merge(isset($cfg['packages']) ? $cfg['packages'] : array(), isset($cfg['packages-dev']) ? $cfg['packages-dev'] : array());
251+
foreach ($packages as $pkg) {
105252
if (isset($pkg['name']) && $pkg['name'] === 'psy/psysh') {
106253
// We're inside a project which requires psysh. We'll use the local Composer autoload.
107254
if (is_file($path . '/vendor/autoload.php')) {
255+
$realPath = realpath($path);
256+
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
257+
258+
if (!$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
259+
fwrite(STDERR, 'Skipping local PsySH at ' . $prettyPath . ' (project is untrusted). Re-run with --trust-project to allow.' . PHP_EOL);
260+
$_SERVER['PSYSH_UNTRUSTED_PROJECT'] = $realPath;
261+
$_ENV['PSYSH_UNTRUSTED_PROJECT'] = $realPath;
262+
putenv('PSYSH_UNTRUSTED_PROJECT='.$realPath);
263+
return;
264+
}
265+
108266
if (realpath($path . '/vendor') !== realpath(__DIR__ . '/../../..')) {
109267
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
110268
}

src/Command/Command.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function run(InputInterface $input, OutputInterface $output): int
6868
$this instanceof PresenterAware ||
6969
$this instanceof ReadlineAware
7070
) {
71-
$this->getShell()->boot();
71+
$this->getShell()->boot($input, $output);
7272
}
7373

7474
return parent::run($input, $output);

src/ConfigPaths.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,69 @@ public function currentDataDir(): ?string
210210
return $dataDirs[0] ?? null;
211211
}
212212

213+
/**
214+
* Get the local config root directory (cwd only, no ancestor walking).
215+
*
216+
* Used for local `.psysh.php` config file detection. Returns the current
217+
* working directory, or null if getcwd() fails.
218+
*/
219+
public function localConfigRoot(): ?string
220+
{
221+
$cwd = \getcwd();
222+
if ($cwd === false) {
223+
return null;
224+
}
225+
226+
return \strtr($cwd, '\\', '/');
227+
}
228+
229+
/**
230+
* Find a project root for trust decisions.
231+
*
232+
* Walks up ancestors to find the nearest composer.json or composer.lock.
233+
* If none found, falls back to the nearest .psysh.php, then to the current
234+
* working directory.
235+
*
236+
* Used for trust decisions on Composer autoload and project-level features.
237+
*/
238+
public function projectRoot(?string $cwd = null): ?string
239+
{
240+
$cwd = $cwd ?? \getcwd();
241+
if ($cwd === false) {
242+
return null;
243+
}
244+
245+
$dir = \strtr($cwd, '\\', '/');
246+
$root = null;
247+
$localConfigRoot = null;
248+
249+
$current = $dir;
250+
$parent = \dirname($current);
251+
252+
while ($current !== $parent) {
253+
if ($root === null && (@\is_file($current.'/composer.json') || @\is_file($current.'/composer.lock'))) {
254+
$root = $current;
255+
}
256+
257+
if ($localConfigRoot === null && @\is_file($current.'/.psysh.php')) {
258+
$localConfigRoot = $current;
259+
}
260+
261+
$current = $parent;
262+
$parent = \dirname($current);
263+
}
264+
265+
if ($root !== null) {
266+
return $root;
267+
}
268+
269+
if ($localConfigRoot !== null) {
270+
return $localConfigRoot;
271+
}
272+
273+
return $dir;
274+
}
275+
213276
/**
214277
* Find real data files in config directories.
215278
*

0 commit comments

Comments
 (0)