Your environment: profile, permissions, write path
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.
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.
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:
"allow": ["Bash(swift test:*)"]
"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.
- It reads the credential in one function, and nowhere else.
- It runs only an allowlisted action.
- It has a dry run that prints the exact request and sends nothing.
- It asks for an explicit confirmation before a real send, unless the run passes
--yes. - It appends one log line per send.
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.
cc-core-21,cc-core-22,cc-core-24,enterprise-07,enterprise-09,enterprise-10— theTool(pattern)rule format, the:*suffix and the space-star spelling, deny then ask then allow with first match winning regardless of specificity,/permissions, the bare-tool deny that removes a tool from context, and themcp__<server>__<tool>scoping, from Permissions.cc-core-39— “Yes, and don't ask again” saves the rule to.claude/settings.local.jsonat the root of the git repository and applies anywhere in it, the file staying beside.claude/settings.jsonoutside a git repository, on Windows and in the other listed cases, and file-edit approvals never saved, from Permissions and Settings, accessed 2026-09-23.cc-core-06,cc-core-07— the six permission-mode values and what each allows without asking, anddefaultModein.claude/settings.json, from Permission modes.cc-core-23,enterprise-11— the settings file locations and their precedence order, and the committed, project-wide.claude/settings.jsonagainst the personal.claude/settings.local.json, from Settings.cc-core-36— what is in the window before your first message: theCLAUDE.mdfiles, the skill descriptions, and the MCP tool names with their schemas deferred by default, from Context window.cc-core-15,cc-automation-01— path-scoped rule files under.claude/rules/, from Memory, and project skills under.claude/skills/, from Skills.cc-automation-09,cc-automation-10,cc-automation-11—--scope projectand the.mcp.jsonit writes, the 25,000-token default limit on MCP tool output, the file a larger text result is saved to,MAX_MCP_OUTPUT_TOKENS, and/mcpas the in-session view of what is attached, from Connect Claude Code to tools via MCP; the output limit re-checked 2026-09-23.cc-core-18—/contextvisualizes what is in the window, from Slash commands.enterprise-06—sandbox.network.allowedDomains, which pre-allows the listed domains while no domain is pre-allowed by default and any other prompts for approval, andstrictAllowlist: trueorallowManagedDomainsOnlyin managed settings, which deny hosts outside the list instead of prompting, from Sandboxing, accessed 2026-09-23.enterprise-01,enterprise-02,enterprise-03,enterprise-04,enterprise-05— the read-only flag and its precedence over every other configuration, disabling write tools even when explicitly requested, and the flags that scope the server further: toolsets, an explicit tool allowlist, an exclude list that takes precedence, and lockdown mode, a best-effort content filter rather than an authorization boundary, from the GitHub MCP server configuration reference, withenterprise-01andenterprise-03accessed 2026-09-23.enterprise-12—gh pr comment <number> --body "<text>"and its--body-fileform, from thegh pr commentmanual.enterprise-13—POST /repos/{owner}/{repo}/pulls/{pull_number}/commentsrequiringbody,commit_idandpath, withlineandsideplacing a comment on the diff andstart_lineandstart_sidefor a range, from Create a review comment for a pull request, re-checked 2026-09-23.enterprise-14— thegh apicall that reaches the same endpoint with the same fields, from REST API endpoints for pull request review comments.cc-core-32— the version this course was checked against, from the Claude Code changelog.