Your environment: profile, permissions, write path

Day 2: Loops and your environment · Lesson 4 of 4 · 16 min

Which connectors can write, what the network lets out, which commands are sanctioned, which models you can pick: those are facts about your workplace, not your ticket. You write them down once, encode the part that has to be enforced as permission rules, and send every write out through one narrow path that somebody reviewed.

Watch first · 5 min

Beyond the basics with Claude Code

Claude, 2026-05-22. Segment 0:00 to 5:00. Watch the order in which they set the session up: what the agent is told about its situation before anything is asked of it. Note which of those decisions sit in a file somebody committed and which get answered live in the terminal, because the section on shared and personal settings files turns on that split. The rest of the 47-minute session is optional, outside today's three hours.

Also worth five minutes, outside today's three hours: How Zalando Engineers Software & AI at Scale — Bartosz Ocytko (bynd code, 2026-09-20), 35:19 to 40:19. Count how many of the things an engineer is handed only answer questions and how many can change something other people see, and listen for who holds the credential in each case.

Constraints are inputs, not obstacles

An agent that does not know the network is closed will plan a dependency install. One that does not know the ticket tracker is read-only will plan to move the ticket. Yesterday's spec said what done means; the environment says what is possible. A constraint the agent knows about becomes a different plan. One it discovers becomes a failed command and a repair loop.

The profile: one file, filled once

env-profile.md, your own file and not the course's env-profile.js, has six rows, reused on every brief: connectors and their direction; egress, which for most teams here means outbound network access is blocked from the agent's sandbox; sanctioned write commands and where they run; the models you can pick between; where shared configuration lives; and the writable directory with the iteration cap. Keep every line generic: no employer, internal system, repository or hostname. Exercise 3 has you fill it in.

Skip it for a one-off script in a throwaway repo with no connectors attached: there is nothing to write down. And a profile is prose; it tells the agent what is possible but binds nothing, so anything that has to hold goes into rules or the environment.

Read-only is a property of the deployment

A connector is read-only because somebody launched it that way, not because the protocol makes it so. On GitHub's MCP server, --read-only disables the write tools. Other flags scope the server further, to whole toolsets or named tools; the configuration reference in Sources lists them. Other vendors' servers have their own switches, or none.

Reads are cheap to permit and not free to use

Split access by direction and most arguments about it end. A read that goes wrong costs tokens and an iteration; a write that goes wrong is visible to other people and sometimes permanent. One policy for both fails either way: too tight, and the agent plans without the ticket; too loose, and the access that read the design can also post.

The bill for a read arrives before you ask a connector anything. At session start the window already holds your CLAUDE.md files, the descriptions of skills Claude may invoke on its own, and every attached MCP tool's name, with schemas deferred by default. Results land on top of that. Claude Code limits MCP tool output to 25,000 tokens by default; a text result over the limit is saved to a file, and the conversation gets a message naming the path. MAX_MCP_OUTPUT_TOKENS raises the limit, moving more of that text into the window: it spends budget; it does not create any.

/mcp shows and manages the servers attached to your session, and /context shows what the window holds; Exercise 3 has you read it with a connector attached and then disabled. A connector you consult once a week is better detached. Fetch the ticket when the plan needs the ticket, not at the start in case: Day 1's budget lesson, pointed at connectors.

The environment as a boundary One rectangle, the environment, holds a single box: the agent, working in your working copy. Three arrows cross the left edge inwards, for three of the read-only connectors: source control, tickets and API docs. One arrow crosses the right edge outwards, the vetted write script, which runs outside the sandbox, and it is the only line leaving the boundary rightwards. Two further paths reach the same right edge and are struck out as blocked: network egress and connector writes. Beneath the boundary, a note: permission rules decide what the agent may attempt; the connector and a locked sandbox allowlist decide what is possible. the environment the agent your working copy source control (read-only) tickets (read-only) API docs (read-only) vetted write script runs outside the sandbox network egress blocked connector writes blocked permission rules decide what the agent may attempt; the connector and a locked sandbox allowlist decide what is possible
One reviewed path goes out; the crossed-out edges are closed in the connector's configuration and in a locked sandbox allowlist.

Rules are enforcement, and that is different from a boundary

Rules are shaped Tool(pattern), such as Bash(gh pr view *) or Read(./.env), and a trailing * after a space means the same as the :* suffix, the spelling the example below uses. A connector's tools take its prefix: mcp__<server> or mcp__<server>__* for all of them, mcp__<server>__<tool> for one.

Evaluation runs deny, then ask, then allow, and the first match ends the decision. Specificity is never a tiebreak. A bare Bash in the deny list removes the tool from the agent's context entirely; a scoped Bash(rm *) only blocks the matches.

Write the deny list first

Start from allow and you list what you happened to think of on a Tuesday. Start from deny and you list what is never acceptable here: shorter, easier to defend in review, and the part a later rule cannot undo. Day 2's example, ../day-2-loops-and-your-environment/starter/settings.example.json from inside day2-work, is written in that order, and settings.example.md beside it explains each rule. A few of them, plus three lines the example file does not carry: the last two in deny and the last in allow belong to the write path, explained further down.

{
  "permissions": {
    "deny":  ["Bash(rm:*)", "Bash(git push:*)", "WebFetch", "Bash(gh pr merge:*)",
              "Bash(gh pr comment:*)", "Bash(gh api:*)"],
    "ask":   ["Bash(gh:*)", "Bash(git commit:*)"],
    "allow": ["Bash(git status)", "Bash(git diff:*)", "Edit(./run-log.md)",
              "Bash(./scripts/post-pr-comment.sh:*)"]
  }
}

The full example file also allows both lanes' test commands; your settings need only your lane's:

Swift lane
"allow": ["Bash(swift test:*)"]
Kotlin lane
"allow": ["Bash(./gradlew test)"]

Modes, and where they are set

The mode sets the default posture before any rule matches. Day 1 named the six values and started a repository in plan mode with defaultMode in .claude/settings.json.

Settings files resolve in a fixed order: managed settings (managed-settings.json, MDM or the console), claude --settings, .claude/settings.local.json, .claude/settings.json, then ~/.claude/settings.json. Managed settings are the one layer nothing overrides. /permissions is where you review and edit the rules.

The file that makes you part of a team

.claude/settings.json is shared and committed, for everyone in the project; .claude/settings.local.json is personal and not shared. Answer “Yes, and don't ask again” to a Bash command and Claude Code saves the rule to .claude/settings.local.json at the root of the git repository, where it applies anywhere in that repository; outside a git repository, on Windows, and in the other cases the Settings page lists, the file stays beside .claude/settings.json. In a clone of this course, that is the clone's root, not day2-work. File-edit approvals are never saved.

The repository's instruction files reach the agent at launch (CLAUDE.md), on a path match (rule files under .claude/rules/) or on request (a skill), each costing context at its own moment. The configuration is committed beside them: .claude/settings.json, and .mcp.json, which --scope project writes for a server meant for the team; that collection, plus the write wrapper below, is “the team's setup”.

Moving a line into the committed file makes your workflow the team's, so review it like any change: a reviewer who reads Bash(gh:*) will ask what it lets through.

What permission rules are not

Rules are policy for this tool's behaviour, not a wall around your machine, network or data: the same command still runs from another session or laptop.

A boundary that holds decides what is possible, not what the agent may attempt. The connector's configuration is one: GitHub's read-only mode takes precedence over every other configuration, disabling write tools even when explicitly requested. The sandbox's network allowlist, sandbox.network.allowedDomains, is another once it is locked. Left alone it pre-allows its domains and prompts for any other domain; strictAllowlist: true, or allowManagedDomainsOnly in managed settings, denies the rest instead. Your profile's egress row says what yours lets out. The service account's permissions on the far side are the third. If a rule is all that stands between a run and production, fix the environment, not the rule.

The shape of a vetted write path

It is a pattern, not a product: a script one teammate wrote and another read, with five properties a reviewer can check in one sitting.

Day 2's starter folder ships one. Run its dry run from inside day2-work, the lane copy every Day 2 command runs in (the shipped sandbox/ is never edited):

bash ../day-2-loops-and-your-environment/starter/scripts/post-pr-comment.sh \
  --repo local-repo --pr 1 --body "Criterion 3 is not covered by the diff." --dry-run
DRY RUN: would POST repos/local-repo/pulls/1/comments
Criterion 3 is not covered by the diff.

It sends only a body because, out of the box, it appends to a local fixture laid out like the REST path; a real inline comment needs the fields below. Ask it for an action outside its list and it names the action, names what it does allow, and exits non-zero.

The approved write surface

For pull-request feedback the surface is small and documented. The GitHub CLI posts a comment with a number and a body, or reads the body from a file:

gh pr comment 1234 --body "Criterion 3 is not covered by the diff."
gh pr comment 1234 --body-file review.md

A comment on a line of the diff uses a different endpoint, POST /repos/{owner}/{repo}/pulls/{pull_number}/comments, which requires body, commit_id and path, plus line, the diff line the comment applies to (side picks LEFT or RIGHT in a split diff). A comment across several lines adds start_line and start_side, and line marks its end. gh api reaches it with the same fields; a wrapper calls that for the inline form. A team can read a surface this small once and trust it, instead of approving every command the agent proposes.

Why the write runs outside the agent's sandbox

The agent proposes the command and the body text; the script, when it runs where your profile says, holds the credential and refuses anything outside its allowlist. That division holds only while the wrapper and the token it reads sit where your profile's sanctioned-write row puts them: outside the agent's sandbox, from your own terminal. The shipped read_token takes the token from the environment, so a session whose shell exports GH_TOKEN has already handed the agent the credential. So keep the token out of the shell you start Claude Code from and export it only in the terminal where you run the wrapper. To check, type this at the Claude Code prompt (the leading ! runs the rest as a shell command in the session); it should print nothing:

! [ -n "$GH_TOKEN" ] && echo exposed

The settings block above makes the division visible: the wrapper's path in allow, the raw commands under it in deny. The pair works because a deny rule matches the commands the agent itself issues, not the gh api call the allowed script makes inside its own process. The recommended way to use the wrapper needs no allow row: the agent proposes the command, and you run it from your own terminal and answer the confirmation. The allow row is for a loop that posts on its own, and it gives up the confirmation: allowing the agent to run the wrapper also admits --yes, so on a run nobody watches only the credential function and the allowlist still decide. Change the wrapper by pull request, and have the reviewer ask what the allowlist now permits that it did not.

When a write path is the wrong tool

If you are the only reader of the output, a wrapper is ceremony: findings written to a file you open afterwards need no allowlist, confirmation or log. Build one when a second person depends on the write.

The sharper failure is a wrapper nobody read. It is worse than pasting by hand, because it looks approved, so the next engineer stops asking what it permits. Its value is the review it received, not the automation. Exercise 4 copies this one into day2-work/scripts/ (so its allow row reads bash scripts/post-pr-comment.sh, not the ./scripts/ form above), has you read it, and has you make its repository allowlist binding, which it currently is not.

Quick check

Your settings allow Bash(gh pr view *) and deny Bash(gh *). Which wins, and why is the more specific rule not the answer?

The deny wins. Deny is evaluated before allow and the first match ends the decision; specificity is never a tiebreak, so a broad deny swallows every narrow allow under it.

Of the wrapper's five properties, which two are what make it safer to allow than raw gh when the agent runs it unwatched?

The credential read in one function and the allowlisted action. The first means the agent need not hold a token, provided the token lives outside the agent's environment as above; the second refuses anything outside the list regardless of what it was asked to do. Both still hold on a --yes run nobody watches, which the confirmation does not. The dry run and the log line are what let a reviewer confirm both.

Sources

Claude Code documentation accessed 2026-09-22 against version 2.1.280; GitHub CLI, REST and MCP server documentation accessed the same day, except where a bullet names another date.