88
99import { Path , getSystemPath , normalize , schema , virtualFs } from '@angular-devkit/core' ;
1010import { NodeJsSyncHost } from '@angular-devkit/core/node' ;
11+ import { realpathSync } from 'node:fs' ;
12+ import { dirname , isAbsolute , relative , resolve as resolveSystemPath , sep } from 'node:path' ;
13+ import { Observable } from 'rxjs' ;
1114import { workflow } from '../../src' ;
1215import { BuiltinTaskExecutor } from '../../tasks/node' ;
1316import { FileSystemEngine } from '../description' ;
@@ -28,6 +31,72 @@ export interface NodeWorkflowOptions {
2831 engineHostCreator ?: ( options : NodeWorkflowOptions ) => NodeModulesEngineHost ;
2932}
3033
34+ /**
35+ * A {@link virtualFs.ScopedHost} that additionally rejects any write/delete/rename whose real
36+ * (symlink-resolved) location escapes the workspace root.
37+ *
38+ * The lexical containment of `ScopedHost` (and the schematics `Tree`, which rejects `..`) does not
39+ * resolve symlinks, so a workspace that contains a symlinked directory could otherwise route a
40+ * schematic/migration write to a file outside the workspace. This mirrors the realpath-based root
41+ * restriction already used by the MCP host (`createRootRestrictedHost`).
42+ */
43+ class WorkspaceRootHost < T extends object > extends virtualFs . ScopedHost < T > {
44+ private readonly _systemRoot : string ;
45+
46+ constructor ( delegate : virtualFs . Host < T > , root : Path ) {
47+ super ( delegate , root ) ;
48+ this . _systemRoot = realpathSync ( getSystemPath ( root ) ) ;
49+ }
50+
51+ private _assertWithinRoot ( path : Path ) : void {
52+ // Resolve the real path, walking up to the first existing ancestor for not-yet-created files.
53+ let current = resolveSystemPath ( getSystemPath ( this . _resolve ( path ) ) ) ;
54+ let real : string ;
55+ for ( ; ; ) {
56+ try {
57+ real = realpathSync ( current ) ;
58+ break ;
59+ } catch ( e ) {
60+ if ( ( e as NodeJS . ErrnoException ) . code !== 'ENOENT' ) {
61+ throw e ;
62+ }
63+ const parent = dirname ( current ) ;
64+ if ( parent === current ) {
65+ throw e ;
66+ }
67+ current = parent ;
68+ }
69+ }
70+
71+ const rel = relative ( this . _systemRoot , real ) ;
72+ if ( rel === '..' || rel . startsWith ( '..' + sep ) || isAbsolute ( rel ) ) {
73+ throw new Error (
74+ `Schematic attempted to access a path outside of the workspace root: ` +
75+ getSystemPath ( this . _resolve ( path ) ) ,
76+ ) ;
77+ }
78+ }
79+
80+ override write ( path : Path , content : virtualFs . FileBuffer ) : Observable < void > {
81+ this . _assertWithinRoot ( path ) ;
82+
83+ return super . write ( path , content ) ;
84+ }
85+
86+ override delete ( path : Path ) : Observable < void > {
87+ this . _assertWithinRoot ( path ) ;
88+
89+ return super . delete ( path ) ;
90+ }
91+
92+ override rename ( from : Path , to : Path ) : Observable < void > {
93+ this . _assertWithinRoot ( from ) ;
94+ this . _assertWithinRoot ( to ) ;
95+
96+ return super . rename ( from , to ) ;
97+ }
98+ }
99+
31100/**
32101 * A workflow specifically for Node tools.
33102 */
@@ -41,7 +110,7 @@ export class NodeWorkflow extends workflow.BaseWorkflow {
41110 let root ;
42111 if ( typeof hostOrRoot === 'string' ) {
43112 root = normalize ( hostOrRoot ) ;
44- host = new virtualFs . ScopedHost ( new NodeJsSyncHost ( ) , root ) ;
113+ host = new WorkspaceRootHost ( new NodeJsSyncHost ( ) , root ) ;
45114 } else {
46115 host = hostOrRoot ;
47116 root = options . root ;
0 commit comments