PRACTICAL GUIDE / zero trust Playwright coding agent sessions

Keep Playwright coding agents out of production

Build a least-privilege Playwright agent workflow that blocks production access, limits credentials, and leaves useful evidence when a run is denied.

By The Testing AcademyUpdated August 4, 202628 min read
All field guides
In this guide6 sections
  1. Set the boundary before the agent receives a task
  2. Know what Playwright isolates, and what it leaves open
  3. Reject an unsafe run before Playwright starts
  4. Collect evidence that identifies the failing boundary
  5. Roll the control into an existing suite without hiding regressions
  6. Accept the cost, and know when this model is the wrong one

What you will learn

  • Set the boundary before the agent receives a task
  • Know what Playwright isolates, and what it leaves open
  • Reject an unsafe run before Playwright starts
  • Collect evidence that identifies the failing boundary

A coding agent fixes one failing test, then runs it with the same shell environment you use for release work. The test passes, but the session has read an unrestricted environment file, contacted the production host, and written authenticated browser state into an artifact directory. A green result says nothing about whether the run stayed inside its authority.

Set the boundary before the agent receives a task

That distinction matters because generated Playwright code is executable repository code. It can import Node.js modules, start child processes, read files, call an API outside the browser, or change the configuration that was supposed to restrain it. A carefully worded prompt is useful guidance, but it is not an access-control boundary.

The practical goal of zero trust Playwright coding agent sessions is smaller than proving an agent is harmless. Give each session only the identity, files, commands, destinations, and retention window required for one task. Deny everything else outside the agent process, then keep evidence that tells a policy refusal from an ordinary product or test failure.

Start with the action the session is allowed to complete. “Repair the checkout test” is too broad because it says nothing about where the test may run or what the repair process may touch. A useful authorization is closer to this: the session may edit one generated test directory, execute one trusted Playwright binary against a loopback application, use one non-privileged account, and write results to one disposable directory. The authorization expires when the job ends.

Playwright currently documents three test-agent roles: planner, generator, and healer. The planner explores the application and runs a seed test, the generator creates executable tests, and the healer executes failures while attempting repairs. Those roles do not need identical authority. A planner may only need an already-running read-only environment. A generator needs a narrow output directory. A healer needs execution and edit rights, which makes it the highest-risk role of the three. Chaining them under one long-lived developer identity quietly gives every later step the broadest permission granted to any earlier step.

Write the boundary in terms an operating system or job runner can enforce. There are seven useful parts:

  1. The session identity identifies this run, not the developer who requested it.
  2. The writable paths contain generated tests and disposable artifacts, not the whole checkout.
  3. Every executable entry names an absolute path and an argument shape. A free-form shell string is not an executable list. Prefer a runner-owned binary, and where the tool is a project dependency instead, such as the Playwright CLI inside node_modules, say so and make the read-only mount the control.
  4. The destination list uses exact origins or network endpoints. A prompt saying “staging only” cannot stop a redirect, a helper script, or a malicious dependency.
  5. The credential set contains a test identity with the least application privilege needed for the scenario.
  6. The time and resource limits stop a healer from retrying forever or accumulating unreviewed artifacts.
  7. The evidence destination is controlled by the runner, so the same agent cannot rewrite the audit record after a denial.

Keep the policy source outside the agent’s writable area. If the model can edit the wrapper, configuration, or allowlist and then rerun, a denial merely asks it to grant itself permission. The same rule applies to seed tests. Playwright’s agent documentation explains that a planner can run a seed test to initialize fixtures, hooks, project dependencies, and global setup. Review a seed test as privileged bootstrap code, not as harmless context attached to a prompt.

An exact origin comparison avoids several familiar mistakes. The URL https://staging.example.test.evil.invalid is not a child of https://staging.example.test. A different port is a different origin. User information before an at sign can make a string look as if it begins with an approved hostname while the actual host is elsewhere. Parse a URL, compare its normalized origin to an allowlist, and let the network layer enforce the same decision. String prefix checks are not suitable.

Paths need canonical treatment for the same reason. Checking that a supplied string begins with tests/generated does not catch a symlink inside that directory that points to a credential file elsewhere. Resolve an existing test file and its allowed root before comparing them. For files the agent is creating, restrict the writable mount itself, then validate the final path after creation. Filesystem permissions are the primary control; path checks give a clear early error and better evidence.

Command approval should operate on an executable plus an argument array. Passing npx playwright test and an agent-controlled suffix through a shell creates another parser with substitutions, separators, redirections, and quoting rules. A broker can instead resolve the installed Playwright executable, construct the fixed test command, append one validated test path, and launch it without a shell. This does not make Playwright a sandbox. It removes a needless command-injection path.

The first worked failure often begins with an innocent environment variable. A developer’s terminal has BASE_URL set to production from a previous smoke test. The generated spec calls page.goto('/checkout'), and the ordinary project configuration resolves that relative path against the inherited value. Nothing in the test looks suspicious. A session-specific runner should replace the environment instead of inheriting it, validate TEST_ORIGIN before Playwright starts, and still enforce egress externally in case the application redirects.

Know what Playwright isolates, and what it leaves open

Playwright gives each test an isolated BrowserContext by default. According to the isolation guide, contexts provide separate cookies, local storage, and session storage in incognito-like profiles. This prevents one test’s browser state from cascading into the next. It is valuable test isolation, but its boundary ends at browser state.

The test file still runs as code in a worker process. A Node.js import can read the filesystem. A setup script can inspect process.env. A package script can start another executable. A helper can use Node’s own HTTP client without involving page or BrowserContext at all. Context isolation does not change the operating-system user, mount permissions, outbound network policy, or secrets delivered to the process. Calling it a security sandbox gives reviewers the wrong assurance.

The baseURL setting is also a convenience, not an origin lock. It lets relative navigation values resolve against a configured URL. A page can still navigate to an absolute production URL, a server can redirect the browser elsewhere, and page code can load third-party resources. Validate the configured origin early because mistakes should fail clearly, but enforce the final destination below Playwright.

Request routing has a different purpose. Playwright’s network APIs can observe, mock, continue, fulfill, or abort browser requests. They are excellent for deterministic tests. They are a weak security perimeter because test code can remove or bypass handlers, non-browser clients are outside that mechanism, and routing changes the behavior being tested. The network documentation also warns that service workers can make requests invisible to page.route and browserContext.route, and recommends serviceWorkers: 'block' when native routing appears to miss traffic. That setting is useful for a diagnostic project, but it changes a service-worker application. It should never replace runner-level egress control.

Authentication state deserves the same precision. The Playwright authentication guide warns that a stored browser-state file may contain cookies and headers capable of impersonating an account. Adding playwright/.auth to .gitignore reduces the chance of an accidental commit. It does not stop a coding agent from reading the file, attaching it to a report, or copying it elsewhere during a session. Use an account created for the job, limit what that account can do in the application, keep its state in a protected session directory, and revoke or discard it after the run.

The following configuration is a useful second check for an agent-only project. It rejects an unexpected origin while the configuration loads, limits test discovery to the generated directory, turns focused tests into failures, disables retries, and records failure traces. None of those options restricts host access. The trusted runner must protect this file from agent edits and enforce the same origin independently.

TypeScript
// playwright.agent.config.ts
import { defineConfig } from '@playwright/test';
import { isAbsolute, join } from 'node:path';

const rawOrigin = process.env.TEST_ORIGIN;
if (!rawOrigin) {
  throw new Error('[session-policy] TEST_ORIGIN is required');
}

const testOrigin = new URL(rawOrigin);
const permittedOrigins = new Set(['http://127.0.0.1:4173']);
if (!permittedOrigins.has(testOrigin.origin) || testOrigin.href !== testOrigin.origin + '/') {
  throw new Error(
    '[session-policy] origin is not allowed: ' + testOrigin.href,
  );
}

const artifactDir = process.env.AGENT_ARTIFACTS;
if (!artifactDir || !isAbsolute(artifactDir)) {
  throw new Error('[session-policy] AGENT_ARTIFACTS must be absolute');
}

export default defineConfig({
  testDir: './tests/generated',
  outputDir: join(artifactDir, 'test-results'),
  forbidOnly: true,
  fullyParallel: false,
  workers: 1,
  retries: 0,
  reporter: [
    ['line'],
    ['json', { outputFile: join(artifactDir, 'agent-results.json') }],
  ],
  use: {
    baseURL: testOrigin.origin,
    serviceWorkers: 'block',
    trace: 'retain-on-failure',
  },
});

Every setting above has a narrow reason. The output and JSON report go to the runner-owned AGENT_ARTIFACTS directory rather than the writable source tree. One worker makes the audit trail easier to follow and avoids concurrent mutation by generated tests, at the cost of elapsed time. Zero retries ensure a healer cannot turn a first failure into an unexplained pass inside this verification run. The JSON reporter produces structured test-run information, as documented in Playwright reporters, while the line reporter keeps the job readable. Trace retention helps with failed tests. A pre-execution policy denial will not have a Playwright trace because the test runner correctly never started.

Blocking service workers is intentionally scoped to this project. If the feature under test is offline behavior, push notifications, caching, or a service-worker upgrade, this configuration changes the subject of the test. Run that scenario in a separate authorized project. Keep the external destination control in both projects, and treat any in-browser listener as supplementary evidence.

Reject an unsafe run before Playwright starts

Fast denials are easier to understand than browser failures. If a request names the wrong origin, an out-of-scope spec, or an unexpected executable, the broker should stop before it creates a worker or browser. That produces a policy result with no ambiguity about selectors, page state, or application health.

This runnable Node.js wrapper accepts exactly one existing TypeScript spec. It resolves the workspace, generated-test root, spec, Playwright executable, home directory, and temporary directory. It launches a fixed argument array with shell processing disabled and builds a small child environment instead of forwarding process.env. Save the trusted wrapper outside the writable checkout, for example as /opt/qa-controls/guarded-playwright.mjs.

TypeScript
// /opt/qa-controls/guarded-playwright.mjs
import { realpathSync } from 'node:fs';
import { isAbsolute, join, relative, sep } from 'node:path';
import { spawn } from 'node:child_process';

const TRUSTED_PATH = '/usr/bin:/bin';

function required(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error('[session-policy] missing environment value: ' + name);
  }
  return value;
}

function isStrictChild(root, candidate) {
  const rel = relative(root, candidate);
  return rel !== '' &&
    rel !== '..' &&
    !rel.startsWith('..' + sep) &&
    !isAbsolute(rel);
}

function escapeForRegex(value) {
  return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
}

if (process.argv.length !== 3) {
  throw new Error(
    '[session-policy] usage: guarded-playwright.mjs <generated-spec>',
  );
}

const workspace = realpathSync(required('AGENT_WORKSPACE'));
const generatedRoot = realpathSync(join(workspace, 'tests/generated'));
const suppliedSpec = isAbsolute(process.argv[2])
  ? process.argv[2]
  : join(workspace, process.argv[2]);
const requestedSpec = realpathSync(suppliedSpec);
const expectedBinary = realpathSync(
  join(workspace, 'node_modules/.bin/playwright'),
);
const requestedBinary = realpathSync(required('PLAYWRIGHT_BIN'));
const agentHome = realpathSync(required('AGENT_HOME'));
const agentTmp = realpathSync(required('AGENT_TMPDIR'));
const agentArtifacts = realpathSync(required('AGENT_ARTIFACTS'));
const testOrigin = new URL(required('TEST_ORIGIN'));

if (!isStrictChild(workspace, generatedRoot)) {
  throw new Error(
    '[session-policy] generated test root escapes the workspace: ' +
      generatedRoot,
  );
}

if (!isStrictChild(generatedRoot, requestedSpec) ||
    !requestedSpec.endsWith('.spec.ts')) {
  throw new Error(
    '[session-policy] spec is outside tests/generated: ' + requestedSpec,
  );
}

// Defence in depth, not a rejection. The trusted entry point sets
// PLAYWRIGHT_BIN to the same workspace path recomputed above, so this compares
// two runner-owned values and cannot reject anything the agent supplies. It
// holds only while node_modules stays read-only, because the file it names
// lives inside the agent's checkout.
if (requestedBinary !== expectedBinary) {
  throw new Error(
    '[session-policy] Playwright executable does not match the workspace ' +
      'installation: ' + requestedBinary,
  );
}

for (const [name, directory] of [
  ['AGENT_HOME', agentHome],
  ['AGENT_TMPDIR', agentTmp],
  ['AGENT_ARTIFACTS', agentArtifacts],
]) {
  if (directory === workspace || isStrictChild(workspace, directory)) {
    throw new Error(
      '[session-policy] ' + name + ' must be outside the workspace',
    );
  }
}

if (testOrigin.origin !== 'http://127.0.0.1:4173' ||
    testOrigin.href !== testOrigin.origin + '/') {
  throw new Error(
    '[session-policy] origin is not allowed: ' + testOrigin.href,
  );
}

const exactSpecPattern = '^' + escapeForRegex(requestedSpec) + '$';
const child = spawn(
  requestedBinary,
  [
    'test',
    '--config',
    'playwright.agent.config.ts',
    exactSpecPattern,
  ],
  {
    cwd: workspace,
    shell: false,
    stdio: 'inherit',
    env: {
      PATH: TRUSTED_PATH,
      HOME: agentHome,
      TMPDIR: agentTmp,
      LANG: process.env.LANG || 'C.UTF-8',
      CI: '1',
      TEST_ORIGIN: testOrigin.origin,
      ALLOWED_BROWSER_ORIGINS: testOrigin.origin,
      PLAYWRIGHT_BROWSERS_PATH: '0',
      AGENT_ARTIFACTS: agentArtifacts,
    },
  },
);

child.once('error', (error) => {
  console.error('[session-policy] failed to start Playwright:', error.message);
  process.exitCode = 1;
});

child.once('exit', (code, signal) => {
  if (signal) {
    console.error('[session-policy] Playwright ended by signal:', signal);
    process.exitCode = 1;
    return;
  }
  process.exitCode = code === null ? 1 : code;
});

Three of those checks can fail for real changes. The generated root itself must resolve beneath the workspace, and a spec symlinked outside that root resolves to the external target and is rejected. A production TEST_ORIGIN fails even if its hostname merely contains an approved string. Removing any required runner-owned value stops the session instead of silently inheriting a developer default.

The Playwright executable comparison is not one of them, and describing it as a rejection would be untrue. The trusted entry point sets PLAYWRIGHT_BIN to the same workspace path the wrapper recomputes, so both sides resolve to one file and no agent-supplied value ever reaches the branch. Keep it as a defence-in-depth assertion that two runner-owned values still agree, which catches a stale entry point or a workspace whose dependencies were never installed. Then be clear about what it cannot establish. The file it names lives inside the agent's checkout, so a session able to write node_modules can replace the CLI's contents without changing its path. A read-only dependency mount, a verified package store, or a Playwright installation owned by the runner outside the workspace is what makes that entry trustworthy. If you want the check to be a real rejection, compare against a path the agent cannot influence and install the CLI there.

The wrapper deliberately does not accept arbitrary Playwright flags. That means an agent cannot add a different configuration, increase retries, select another output directory, or start UI mode through this entry point. The Playwright command-line documentation supports the test command, config option, and positional test filter used here. When the team needs another supported argument, add it to the broker after review rather than exposing a raw suffix.

There are costs. The selected spec must already exist because realpathSync rejects a missing path. Teams that create and run a new file in one step need a writable generated directory, followed by validation after the write and before execution. Projects whose tests live across several directories need explicit roots. A single fixed origin does not cover a legitimate identity provider or asset host. Each exception expands authority, so record why it exists and enforce it in the real network policy as well.

Do not mistake the small child environment for complete containment. Clearing inherited variables prevents common credential leaks such as cloud tokens, database URLs, proxy credentials, and production base URLs. It cannot stop the process from reading any file available to its operating-system user. The agent workspace, trusted control files, package store, browser cache, home directory, and artifact directory still need appropriate mounts and permissions.

The second worked failure is a symlink escape. Suppose an agent creates tests/generated/login.spec.ts as a link to a file under a developer’s home directory, then asks the runner to execute it. A string-prefix validator sees the requested path under tests/generated. The wrapper resolves the link and prints its own stable policy message beginning with “[session-policy] spec is outside tests/generated”. Playwright never starts. If the same file is a normal generated spec but its import reads outside the workspace, this path check will not catch the import. Read-only mounts and a separate operating-system identity are what contain that case.

A third variation looks like command injection in a job log. The requested filename contains a semicolon followed by another command. The wrapper does not concatenate that value into a shell string, and shell is false. More importantly, realpathSync requires the argument to identify an actual file within the generated root. The string is either a literal valid filename or a policy error; it is never reinterpreted as shell syntax. This removes one parser, but it does not review what valid TypeScript inside the spec does.

Collect evidence that identifies the failing boundary

A useful investigation begins by asking whether the denial happened before or after Playwright launched. A broker rejection has a session-policy record, no Playwright worker, and no trace. A test failure has a Playwright result with a project, test title, status, errors, and attachments. A network enforcement denial has a destination, process or workload identity, and rule decision from the runner’s network layer. Combining those records is much more informative than labeling all three “agent failed.”

Add browser-level observation because it connects a destination to a test action. The fixture below records HTTP and HTTPS origins emitted through the BrowserContext request event, attaches the observed set to the Playwright result, and fails when it sees an origin outside an exact allowlist. It is an assertion about Playwright-observable browser traffic. It is not the egress firewall, and it says nothing about a Node.js fetch, another process, DNS, or a client outside this context.

TypeScript
// tests/fixtures/agent-test.ts
import {
  expect,
  test as base,
  type Request,
} from '@playwright/test';

type SecurityFixtures = {
  originAudit: void;
};

function readAllowedOrigins(): Set<string> {
  const raw = process.env.ALLOWED_BROWSER_ORIGINS;
  if (!raw) {
    throw new Error('ALLOWED_BROWSER_ORIGINS is required');
  }

  const origins = raw.split(',').map((value) => value.trim());
  if (origins.some((value) => value.length === 0)) {
    throw new Error('ALLOWED_BROWSER_ORIGINS contains an empty value');
  }

  return new Set(origins.map((value) => {
    const url = new URL(value);
    if (url.href !== url.origin + '/') {
      throw new Error('Expected an origin without a path: ' + value);
    }
    return url.origin;
  }));
}

export const test = base.extend<SecurityFixtures>({
  originAudit: [async ({ context }, use, testInfo) => {
    const allowed = readAllowedOrigins();
    const observed = new Set<string>();

    context.on('request', (request: Request) => {
      const url = new URL(request.url());
      if (url.protocol === 'http:' || url.protocol === 'https:') {
        observed.add(url.origin);
      }
    });

    await use();

    const observedOrigins = [...observed].sort();
    const violations = observedOrigins.filter(
      (origin) => !allowed.has(origin),
    );

    await testInfo.attach('observed-browser-origins', {
      body: Buffer.from(JSON.stringify({
        allowedOrigins: [...allowed].sort(),
        observedOrigins,
        violations,
      }, null, 2)),
      contentType: 'application/json',
    });

    expect(
      violations,
      'Browser requests reached origins outside the session allowlist',
    ).toEqual([]);
  }, { auto: true }],
});

export { expect } from '@playwright/test';

The assertion is capable of exposing a product change. If a checkout page adds an image, analytics call, or redirect on another origin, the observed set changes and the test fails. If no browser request occurs, the attachment says that plainly instead of fabricating coverage. When the application legitimately depends on another destination, review the dependency and add its exact origin to the runner’s network policy and the diagnostic list together.

Generated tests must import test from this fixture for the automatic audit to run. That dependency is another reason to make the agent configuration and seed example immutable. A custom import can bypass the fixture while still being valid Playwright code. The external network rule remains authoritative when generated code ignores the test convention.

Final-page assertions catch a narrower but common failure that request totals can obscure. A staging application may redirect /checkout to its production canonical URL because its host configuration is wrong. The initial baseURL is approved, yet the resulting page is not. This test checks the actual page origin after navigation and still lets the automatic fixture report every observed HTTP origin.

TypeScript
// tests/generated/checkout-origin.spec.ts
import { test, expect } from '../fixtures/agent-test';

test('checkout remains on the authorized application', async ({ page }) => {
  const expectedOrigin = new URL(process.env.TEST_ORIGIN as string).origin;

  await page.goto('/checkout');

  expect(new URL(page.url()).origin).toBe(expectedOrigin);
  await expect(
    page.getByRole('heading', { name: 'Checkout' }),
  ).toBeVisible();
});

Trace Viewer helps determine why that assertion failed. With trace set to retain-on-failure, open the saved archive locally with npx playwright show-trace followed by the trace path. In the Network tab, Playwright shows request URLs, methods, status codes, durations, sizes, headers, and bodies for recorded requests. The Actions view ties page.goto and later interactions to snapshots and logs. Inspect the redirect sequence and the final page URL instead of assuming baseURL was ignored.

Treat the trace itself as sensitive evidence. Network headers and bodies may contain credentials or personal data, and DOM snapshots may contain customer information. Store traces with restricted access, a defined retention period, and the same session identifier as the policy log. Playwright documents that its hosted trace viewer loads an uploaded trace in the browser without transmitting it externally, but access to the trace file still matters. Opening a remote trace URL also grants whoever can use that URL access to the file.

The near-miss is a service outage. The broker approves the exact staging origin, the browser starts, and page.goto fails because the service is unavailable. That is not a zero-trust denial. Evidence shows an allow decision for the destination, a Playwright test result, and a navigation failure against the expected host. Repair the service, DNS, certificate, or runner connectivity instead of broadening the allowlist.

Another near-miss produces the same “network denied” label but comes from Node code. A global setup helper calls a production API with Node’s fetch while the page only contacts loopback. The browser-origin attachment is clean because the request did not originate in that BrowserContext. The external network log names the worker process or workload and production destination. That disagreement is useful evidence: inspect setup files and imports rather than adding page listeners. Browser diagnostics and egress enforcement answer different questions.

An expired test identity can also resemble blocked access. The run remains on the approved origin but receives an authentication redirect or unauthorized response. The trace shows the expected host and login flow, while the policy layer shows no destination denial. Replace the short-lived test identity or fix authentication setup. Granting broader network access cannot repair an expired cookie.

Roll the control into an existing suite without hiding regressions

Begin in observation mode, but make “observation” precise. Run the existing suite in a restricted non-production environment and collect the actual destinations, file reads available from runner telemetry, commands launched, generated paths, authentication inputs, and artifact types. Do not turn everything observed into an allowlist automatically. Existing tests may already call obsolete vendors, personal endpoints, or production services. Every retained permission needs an owner and a test requirement.

Separate agent roles during the inventory. A planner that explores a read-only catalog should not inherit the payment test account needed by a generator. A generator that only writes specs should not launch arbitrary package scripts. A healer may need repeated Playwright execution, but it should not be able to expand its own retry budget or edit trusted fixtures. Role-specific sessions make denied actions understandable and reduce the impact of a compromised instruction or dependency.

Move next to a dedicated agent project. Keep normal human-authored projects unchanged while a small set of generated tests uses playwright.agent.config.ts, the trusted fixture, a test service account, and the restricted runner. Compare product assertions between the normal and agent projects. Differences caused by one worker, blocked service workers, a fresh account, or missing third-party hosts should be classified before rollout. Otherwise a security control can quietly remove the behavior the suite was meant to cover.

Protect the agent definition and regenerate it when Playwright is upgraded, as the official agent guide recommends. Review the resulting changes before use. An updated definition may introduce different instructions or tools, and a newly generated test may rely on a newer runner behavior. Pinning dependencies gives repeatability for a session; reviewing upgrades prevents the pin from becoming permanent neglect.

The shell entry point below demonstrates CI wiring on a Unix runner whose network and mounts have already been restricted by trusted job configuration. The job owner, not the agent, supplies the absolute workspace and session directories. It creates private home and temporary directories, clears the inherited environment, supplies a fixed PATH, and invokes the trusted wrapper through an absolute Node.js path. The network restriction is intentionally not faked in this script because its implementation belongs to the CI platform or container runtime.

Provision the matching Playwright browsers before this session and keep node_modules read-only during it. The wrapper sets PLAYWRIGHT_BROWSERS_PATH to 0, the documented hermetic mode that looks for browser binaries under Playwright's local package directory. Without either that setting or an explicitly approved shared browser path, replacing HOME with an empty directory can make an otherwise valid run fail at browser launch.

Shell
#!/bin/bash
set -euo pipefail

readonly TRUSTED_PATH="/usr/bin:/bin"
readonly ENV_BIN="/usr/bin/env"
readonly NODE_BIN="/usr/bin/node"
readonly MKDIR_BIN="/bin/mkdir"
readonly CHMOD_BIN="/bin/chmod"
readonly PATH="$TRUSTED_PATH"
export PATH

for binary in "$ENV_BIN" "$NODE_BIN" "$MKDIR_BIN" "$CHMOD_BIN"; do
  if [[ ! -x "$binary" ]]; then
    echo "trusted executable is missing: $binary" >&2
    exit 69
  fi
done

if [[ -z "${AGENT_WORKSPACE:-}" || -z "${SESSION_ROOT:-}" ]]; then
  echo "AGENT_WORKSPACE and SESSION_ROOT are required" >&2
  exit 64
fi

if [[ "$#" -ne 1 ]]; then
  echo "pass one generated .spec.ts file" >&2
  exit 64
fi

readonly AGENT_WORKSPACE="$AGENT_WORKSPACE"
readonly SESSION_ROOT="$SESSION_ROOT"
readonly SPEC_PATH="$1"

case "$AGENT_WORKSPACE" in
  /*) ;;
  *) echo "AGENT_WORKSPACE must be absolute" >&2; exit 64 ;;
esac

case "$SESSION_ROOT" in
  /*) ;;
  *) echo "SESSION_ROOT must be absolute" >&2; exit 64 ;;
esac

umask 077
"$MKDIR_BIN" -p -- \
  "$SESSION_ROOT/home" \
  "$SESSION_ROOT/tmp" \
  "$SESSION_ROOT/artifacts"
"$CHMOD_BIN" 700 \
  "$SESSION_ROOT/home" \
  "$SESSION_ROOT/tmp" \
  "$SESSION_ROOT/artifacts"

exec "$ENV_BIN" -i \
  PATH="$TRUSTED_PATH" \
  LANG="C.UTF-8" \
  AGENT_WORKSPACE="$AGENT_WORKSPACE" \
  AGENT_HOME="$SESSION_ROOT/home" \
  AGENT_TMPDIR="$SESSION_ROOT/tmp" \
  AGENT_ARTIFACTS="$SESSION_ROOT/artifacts" \
  PLAYWRIGHT_BIN="$AGENT_WORKSPACE/node_modules/.bin/playwright" \
  TEST_ORIGIN="http://127.0.0.1:4173" \
  "$NODE_BIN" /opt/qa-controls/guarded-playwright.mjs "$SPEC_PATH"

Two constants in this workflow are hard-coded to a Debian-style layout, and both must be changed together for a different image. The first is NODE_BIN, set to /usr/bin/node in the shell entry point. The second is TRUSTED_PATH, set to /usr/bin:/bin in the shell entry point and again in the wrapper, which passes it to the child as PATH. Node's own directory has to be inside TRUSTED_PATH, because the Playwright CLI is a JavaScript file whose /usr/bin/env node shebang and the sh shim that some package managers install both resolve node through that search path. On the official node:* container images and on GitHub-hosted runners, Node.js lives at /usr/local/bin/node, so a reader who follows a disclaimer naming only NODE_BIN gets a session that passes every policy check and then dies when the CLI is executed. Set NODE_BIN to the image's pinned absolute path and put that path's directory in both copies of TRUSTED_PATH, for example /usr/local/bin:/usr/bin:/bin.

Absolute executable paths prevent an inherited PATH entry from substituting a hostile env, node, mkdir, or chmod before policy code runs. The fixed child PATH also decides which Node.js executable a shebang finds, so binary resolution cannot be undermined by a different interpreter earlier in an inherited search path. One portability detail is easy to get wrong here: mkdir -p -- is accepted by both GNU and BSD implementations, while BSD chmod accepts options only before the mode operand and treats a following -- as a file name. On macOS the -- form fails with chmod: --: No such file or directory and exit status 1, after the directories exist and before exec, so the chmod call above deliberately omits it. The session directories are created under umask 077 in any case, and the explicit mode is a second statement of intent.

Run the service under test as a separate trusted step or workload. If the agent can change the server start command, it can execute code with whatever identity starts that server. If it can write the server’s source tree, a browser-only test permission becomes application-code execution. Some teams accept that for an isolated disposable environment, but it should be an explicit authority, not an accidental side effect of “let the agent fix the test.”

Introduce blocking rules in an order that preserves diagnosis. First deny production destinations for all agent sessions. No browser-generation task needs an accidental production fallback. Next remove developer and release credentials, then narrow writable paths and executable arguments. Finally tune approved third-party destinations and artifact retention. This ordering catches the most damaging mistakes early while giving the team time to separate real dependencies from historical leakage.

Keep a human review point before generated changes leave the agent branch or workspace. Review imports, global setup, configuration changes, skipped tests, weakened assertions, new network destinations, authentication state handling, and package-script edits. A healer that changes an assertion from a business outcome to mere element visibility may produce a green run without repairing the test. Zero-trust containment limits what the session can reach; it does not establish that the patch is a good test.

Do not use pass rate as the rollout gate. Track policy denials separately from product failures, test defects, and infrastructure failures. A denied production request is a successful control and a failed session. A selector failure is a test or product question, not evidence that containment works. An absent audit record is an observability defect even if the suite is green. These categories need different owners and should not be averaged into one success percentage.

Artifact cleanup is part of the rollout. The agent’s home, temporary directory, traces, screenshots, videos, downloads, reports, and authentication state can outlive the browser. Retain only what an investigation or review needs. Make deletion a runner-owned lifecycle action after upload or expiration, not a command the model may choose to skip. Before retention, inspect whether the artifact format can include request headers, response bodies, page content, or access tokens.

Accept the cost, and know when this model is the wrong one

Least privilege adds friction. A new application origin, identity provider, font host, object store, or test-data API can stop a previously working generated test. The correct response is not a wildcard. Confirm that the dependency belongs in the scenario, add the narrow endpoint to the authoritative policy, update the browser diagnostic list if relevant, and record the owner. This is slower than inheriting a developer environment because each new capability becomes visible.

Fresh test identities also cost time. Login setup may dominate a short suite, and account provisioning needs reliable cleanup. Shared storage state is faster, but it couples parallel tests and increases the value of a leaked file. Playwright’s authentication guidance recommends shared state only where tests can run simultaneously without interfering with server-side state. Agent sessions should go further by using an account whose application permissions and lifetime match the job.

Single-worker execution makes a first rollout easier to audit but reduces throughput. Once the tests and fixtures are free of shared mutation, increase concurrency deliberately and retain a session identifier plus worker and test identity in the evidence. Do not assume BrowserContext isolation prevents server-side collisions. Two isolated contexts can still edit the same cart, user, order, or feature flag.

Trace retention consumes storage and exposes more data to reviewers. Retain-on-failure avoids keeping successful traces, but a deliberate test failure could still produce a sensitive archive. Teams with strict data boundaries may disable traces for some scenarios and rely on redacted application logs plus a structured reporter. That sacrifices the action, DOM, and network detail that makes browser failures quick to diagnose. Choose the evidence based on the data classification, not convenience.

Blocking service workers is wrong when the service worker is part of the feature. A PWA cache test, offline flow, background update, or request behavior mediated by Mock Service Worker needs a project where service workers remain enabled. Keep true network enforcement outside the browser, collect the evidence the scenario supports, and document that the origin-audit fixture has reduced visibility. Do not force the diagnostic configuration onto a test whose purpose it invalidates.

An agent session is also the wrong place for an intentionally destructive production test. If the organization has a justified production smoke or recovery exercise, run it through a separate, human-approved workflow with its own identity, narrow action set, change window, rollback plan, and audit trail. Adding production to the coding agent’s ordinary destination list turns an exceptional operation into ambient authority.

Some tests legitimately cross many domains, such as federated login, payment redirects, embedded support tools, or downloadable content from signed object URLs. Exact destination control remains possible, but it takes ownership and maintenance. If a signed URL uses unpredictable hosts, place a controlled test substitute in the agent environment or move that scenario to a more tightly supervised job. A broad wildcard makes the test easy to run and hard to trust.

Do not use Playwright request routing as the main fix for any of these cases. Routing is appropriate when the test intends to mock or inspect browser traffic. A security control must still work when a spec imports a different fixture, opens another context, uses an API client, starts a child process, or changes the route handler. Put the deny decision where the untrusted session cannot replace it.

Finally, do not grant a healer broader authority just because it cannot repair a failure. An inability to reach a service, read a protected fixture, or edit a trusted configuration may be the intended boundary working correctly. Stop the session, preserve the denial and Playwright evidence that exists, and ask the service owner whether the task should be redesigned. Expanding access during an automated retry loop is the point at which a small test repair becomes an unreviewed operational change.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

AI Tester Blueprint

Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.

From the instructor behind this guide.

AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Can Playwright configuration sandbox a coding agent?

No. Playwright configuration controls the test runner and browser options, but it does not remove filesystem, process, credential, or host-network access from the agent. Put those boundaries in the runner or container that launches the session.

Does a fresh BrowserContext make an agent session safe?

Browser contexts isolate cookies, local storage, and session storage between tests. They do not isolate the Node.js process, the working tree, environment variables, or commands that the coding agent can invoke.

Should an agent reuse my normal Playwright storage state?

Treat an authentication state file as a credential, because it can contain cookies and headers that allow account impersonation. Give the agent a short-lived, low-privilege test identity and keep the state outside its writable source tree.

How do I prove generated tests never reached production?

Enforce destination rules at the network boundary and retain its deny log. Browser request events and a Playwright trace help identify the test action involved, but neither should be the only control because code can make requests outside the page.

What should a denied Playwright agent run record?

Keep the session identity, immutable policy version, requested executable and arguments, canonical test path, destination decision, exit status, and artifact locations. Redact secrets before retention and distinguish a policy denial from a test assertion failure.