@@ -12,9 +12,11 @@ import {
1212 writeFile ,
1313} from "node:fs/promises" ;
1414import { tmpdir } from "node:os" ;
15- import { basename , dirname , join , resolve , sep } from "node:path" ;
15+ import { basename , dirname , join , matchesGlob , relative , resolve , sep } from "node:path" ;
1616
1717import type {
18+ EnvPolicy ,
19+ EnvSource ,
1820 MountAccessMode ,
1921 ObservabilityEvent ,
2022 WorkspaceController ,
@@ -24,6 +26,14 @@ import type {
2426 WorkspaceSearchResult ,
2527} from "@agent-container/types" ;
2628
29+ import {
30+ filterEnvValues ,
31+ parseEnvFile ,
32+ type ResolvedEnvPolicy ,
33+ resolveEnvPolicy ,
34+ serializeEnvFile ,
35+ } from "./env.js" ;
36+
2737interface MountBinding {
2838 mountPath : string ;
2939 sourcePath : string ;
@@ -41,6 +51,17 @@ function isWorkspaceWriteDeniedError(error: unknown): error is WorkspaceWriteDen
4151 return error instanceof Error && error . name === "WorkspaceWriteDeniedError" ;
4252}
4353
54+ class WorkspaceReadDeniedError extends Error {
55+ public constructor ( message : string ) {
56+ super ( message ) ;
57+ this . name = "WorkspaceReadDeniedError" ;
58+ }
59+ }
60+
61+ function isWorkspaceReadDeniedError ( error : unknown ) : error is WorkspaceReadDeniedError {
62+ return error instanceof Error && error . name === "WorkspaceReadDeniedError" ;
63+ }
64+
4465type EmitEvent = ( event : Omit < ObservabilityEvent , "timestamp" > ) => Promise < void > ;
4566
4667function normalizeMountPath ( value : string ) : string {
@@ -140,6 +161,19 @@ function entryKindFromStat(
140161 return "other" ;
141162}
142163
164+ function sourceLogicalPath (
165+ root : string ,
166+ source : Extract < EnvSource , { type : "file" } > ,
167+ ) : string | undefined {
168+ const sourcePath = resolve ( root , source . path ) ;
169+ const relativePath = relative ( root , sourcePath ) ;
170+ if ( relativePath === "" || relativePath . startsWith ( ".." ) || relativePath . startsWith ( `..${ sep } ` ) ) {
171+ return undefined ;
172+ }
173+
174+ return normalizeLogicalPath ( relativePath . replace ( / \\ / gu, "/" ) ) ;
175+ }
176+
143177export class LocalWorkspaceController implements WorkspaceController {
144178 public readonly root : string ;
145179
@@ -151,23 +185,40 @@ export class LocalWorkspaceController implements WorkspaceController {
151185
152186 readonly #shadowRoot: string | undefined ;
153187
188+ readonly #envPolicy: ResolvedEnvPolicy | undefined ;
189+
190+ readonly #envSourcePaths: ReadonlySet < string > ;
191+
192+ readonly #denyRead: readonly string [ ] ;
193+
154194 private constructor ( options : {
155195 root : string ;
156196 mode : "live" | "shadow" ;
157197 mounts : readonly MountBinding [ ] ;
158198 shadowRoot ?: string ;
159199 emit ?: EmitEvent ;
200+ envPolicy ?: ResolvedEnvPolicy ;
201+ denyRead : readonly string [ ] ;
160202 } ) {
161203 this . root = options . root ;
162204 this . mode = options . mode ;
163205 this . #mounts = options . mounts ;
164206 this . #shadowRoot = options . shadowRoot ;
165207 this . #emit = options . emit ;
208+ this . #envPolicy = options . envPolicy ;
209+ this . #denyRead = options . denyRead ;
210+ this . #envSourcePaths = new Set (
211+ options . envPolicy ?. sources
212+ . filter ( ( source ) : source is Extract < EnvSource , { type : "file" } > => source . type === "file" )
213+ . map ( ( source ) => sourceLogicalPath ( options . root , source ) )
214+ . filter ( ( path ) : path is string => path !== undefined ) ?? [ ] ,
215+ ) ;
166216 }
167217
168218 public static async create (
169219 options : WorkspaceOptions ,
170220 emit ?: EmitEvent ,
221+ policy ?: { env ?: EnvPolicy } ,
171222 ) : Promise < LocalWorkspaceController > {
172223 const workspaceRoot = resolve ( options . root ) ;
173224 const mode = options . mode ?? "live" ;
@@ -198,19 +249,37 @@ export class LocalWorkspaceController implements WorkspaceController {
198249 }
199250
200251 mounts . sort ( ( left , right ) => right . mountPath . length - left . mountPath . length ) ;
252+ const envPolicy =
253+ policy ?. env === undefined ? undefined : await resolveEnvPolicy ( activeRoot , policy . env ) ;
201254
202255 return new LocalWorkspaceController ( {
203256 root : activeRoot ,
204257 mode,
205258 mounts,
206259 shadowRoot,
207260 emit,
261+ envPolicy,
262+ denyRead : options . denyRead ?? [ ] ,
208263 } ) ;
209264 }
210265
211266 public async read ( path : string ) : Promise < Uint8Array > {
212267 try {
213268 const target = await this . #resolveTarget( path ) ;
269+ const envContent = await this . #tryReadEnvSource( target ) ;
270+ if ( envContent !== undefined ) {
271+ const content = Buffer . from ( envContent , "utf8" ) ;
272+ await this . #emitEvent( {
273+ scope : "workspace" ,
274+ action : "read" ,
275+ outcome : "success" ,
276+ target : target . logicalPath ,
277+ detail : `${ content . byteLength } bytes` ,
278+ } ) ;
279+ return content ;
280+ }
281+
282+ await this . #assertReadable( target . logicalPath ) ;
214283 const content = await readFile ( target . physicalPath ) ;
215284 await this . #emitEvent( {
216285 scope : "workspace" ,
@@ -221,7 +290,9 @@ export class LocalWorkspaceController implements WorkspaceController {
221290 } ) ;
222291 return content ;
223292 } catch ( error ) {
224- await this . #emitFailure( "read" , path , error ) ;
293+ if ( ! isWorkspaceReadDeniedError ( error ) ) {
294+ await this . #emitFailure( "read" , path , error ) ;
295+ }
225296 throw error ;
226297 }
227298 }
@@ -359,7 +430,16 @@ export class LocalWorkspaceController implements WorkspaceController {
359430 continue ;
360431 }
361432
362- const content = await this . readText ( path ) ;
433+ let content : string ;
434+ try {
435+ content = await this . readText ( path ) ;
436+ } catch ( error ) {
437+ if ( isWorkspaceReadDeniedError ( error ) ) {
438+ continue ;
439+ }
440+
441+ throw error ;
442+ }
363443 const lines = content . split ( / \r ? \n / u) ;
364444 for ( const [ index , line ] of lines . entries ( ) ) {
365445 const haystack = caseSensitive ? line : line . toLowerCase ( ) ;
@@ -484,6 +564,32 @@ export class LocalWorkspaceController implements WorkspaceController {
484564 } ;
485565 }
486566
567+ async #tryReadEnvSource( target : ResolvedTarget ) : Promise < string | undefined > {
568+ if ( ! this . #envSourcePaths. has ( target . logicalPath ) || this . #envPolicy === undefined ) {
569+ return undefined ;
570+ }
571+
572+ const content = await readFile ( target . physicalPath , "utf8" ) ;
573+ const filtered = filterEnvValues ( parseEnvFile ( content ) , this . #envPolicy) ;
574+ return serializeEnvFile ( filtered ) ;
575+ }
576+
577+ async #assertReadable( logicalPath : string ) : Promise < void > {
578+ const isEnvLike = logicalPath
579+ . split ( "/" )
580+ . some ( ( segment ) => segment === ".env" || segment . startsWith ( ".env." ) ) ;
581+ const isDenied = this . #denyRead. some ( ( pattern ) => matchesGlob ( logicalPath , pattern ) ) ;
582+ if ( isEnvLike || isDenied ) {
583+ await this . #emitEvent( {
584+ scope : "workspace" ,
585+ action : "read-denied" ,
586+ outcome : "denied" ,
587+ target : logicalPath ,
588+ } ) ;
589+ throw new WorkspaceReadDeniedError ( `Path is not readable: ${ logicalPath } ` ) ;
590+ }
591+ }
592+
487593 async #emitEvent( event : Omit < ObservabilityEvent , "timestamp" > ) : Promise < void > {
488594 if ( this . #emit === undefined ) {
489595 return ;
0 commit comments