In my previous blog I gave an overview of Retrieval Augmented Generation (RAG). In this post I'll dive deeper into the "retrieval" part of the solution.
Retrieval is the first step in the RAG pipeline, and in many ways the most important. Before any augmentation or generation can happen, you need to find the right information.
Retrieval is the process of querying a data source and returning the documents or records most relevant to a given input. That input might be a user's question, a search query, or some other piece of text. The output is a set of documents that will be passed on to the augmentation step.
The data source could be almost anything - a database, a document store, a knowledge base, an email archive, or a collection of web pages. What makes retrieval in RAG interesting is that it often goes beyond a simple keyword lookup. Rather than asking "does this document contain these words?", we often want to ask "is this document relevant to what the user is asking?".
Retrieval of relevant information, rather than just augmenting the prompt with all available data, is an incredibly important step in RAG processing. This is because as prompt input sizes increase, the responses from the LLM degrade. Limiting the context to a smaller, targeted subset of relevant information is the best way to get useful responses.
Alongside this, good retrieval is also what determines the quality of everything that comes after it. If the wrong documents are fetched, the generated answer will be wrong or incomplete, no matter how well the rest of the pipeline is designed.
Because retrieval surfaces an explicit set of source documents, those sources can be specifically cited by any answers - meaning that answers can be validated and audited more easily.
Finally, the retrieval index can be updated continuously as new data arrives, this means the knowledge available to your RAG system can grow and evolve over time without any retraining or fine-tuning.
The simplest form of retrieval is a structured query against a database. If your data lives in a relational database, a query language like SQL lets you filter records precisely - by date range, category, status, or any other field in your schema.
This approach works really well when you know exactly what you're looking for and your data is well-structured. It's fast, deterministic, and easy to understand why certain documents have been retrieved.
The downside is that it's brittle - it requires you to know the right field values up front, and it can't handle anything fuzzy or conceptual. If a user asks "what went wrong last month?", a database query can't help you unless you already know exactly which fields represent "what went wrong".
Database queries are often used as a pre-filtering step in more sophisticated RAG pipelines - narrowing a large dataset down to a relevant subset before applying a more intelligent retrieval method.
Keyword search matches documents based on the presence of specific words or phrases.
This is a fast and easily-understandable approach - it's still very easy to understand why certain results have been returned. Tools like Azure AI Search and Elasticsearch are built for exactly this, and they support things like:
The limitation is that keyword search can only match documents that contain the words you searched for. A document that says "the package arrived two weeks late" won't match a search for "slow delivery" even though it means the same thing. For that, you need something more semantic.
Vector search addresses the limitation of keyword search by working with meaning rather than words. It uses embeddings (numerical representations of text) to find documents that are conceptually similar to a query, even when they share no words in common.
An embedding is produced by passing text through an embedding model (such as OpenAI's text-embedding-3-small or open-source alternatives like sentence-transformers). The model outputs a vector (an array of numbers) that encodes the semantic meaning of the text. Crucially, texts that mean similar things end up with vectors that are close together in this high-dimensional space.
For example, when you embed the text "Shipping took forever" and "Delivery was very slow", they'll have similar vector representations despite using different words, because they mean similar things. Meanwhile, "Material feels cheap and flimsy" will be far away in vector space.
In practice, generating an embedding is a single API call. Here's a minimal example using Azure OpenAI with Azure AD authentication:
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from openai import AzureOpenAI
credential = DefaultAzureCredential()
token_provider = get_bearer_token_provider(
credential,
"https://cognitiveservices.azure.com/.default"
)
client = AzureOpenAI(
azure_endpoint="https://your-foundry-account.services.ai.azure.com/",
api_version="2024-02-01",
azure_ad_token_provider=token_provider,
)
def get_embedding(text: str) -> list[float]:
response = client.embeddings.create(
input=text,
model="text-embedding-3-small"
)
return response.data[0].embedding
You would call get_embedding on each chunk of your documents to build up your retrieval index.
When a user submits a query, that query is also embedded, and the retrieval system finds the documents whose vectors are closest to the query vector - typically using cosine similarity to calculate the "distance" between vectors. The most semantically similar documents are returned, regardless of exact wording.
Using the same model for both is essential, as different models produce incompatible vector spaces.
Using Azure AI Search, a vector search looks like this:
from azure.search.documents.models import VectorizedQuery
from azure.search.documents import SearchClient
from azure.identity import DefaultAzureCredential
search_client = SearchClient(
endpoint="https://your-search-service.search.windows.net",
index_name="reviews",
credential=DefaultAzureCredential(),
)
def vector_search(query: str, top: int = 5) -> list[dict]:
query_vector = get_embedding(query) # using the function from above
vector_query = VectorizedQuery(
vector=query_vector,
k=top,
fields="content_vector", # the field in your index that stores embeddings
)
results = search_client.search(
search_text=None,
vector_queries=[vector_query],
select=["id", "content", "source"],
top=top,
)
return [dict(doc) for doc in results]
Calling vector_search("What are customers saying about delivery?") will return the reviews whose embeddings are closest to the query embedding, even if they use completely different words.
One important consideration is chunking - how you split your documents before embedding them for the index. Embedding a 50-page document as a single vector will lose a lot of nuance, so in practice documents are split into smaller chunks (paragraphs, sections, or fixed-size windows) before being embedded and indexed. Choosing the right chunk size and overlap is one of the more impactful tuning decisions in a RAG system.
The index itself is typically stored and queried using a vector database, such as Azure AI Search.
In practice, neither keyword search nor vector search is universally better, both are powerful in different situations. Hybrid retrieval combines both, running keyword and vector searches in parallel and then merging the results.
The most common merging strategy is Reciprocal Rank Fusion (RRF), which combines the ranked lists from each search method by giving each result a score based on its position in each list. This allows you to narrow down to documents that rank well in both searches. If a document is relevant both in its keywords and semantically, you can be fairly confident it's what the user is looking for.
Azure AI Search supports hybrid retrieval natively, making it a good fit for RAG pipelines on Azure.
The retrieval types above each require different tooling, but in a real RAG system there are a few layers that nearly always appear.
Embedding models form the basis of vector search. Popular choices include OpenAI's text-embedding-3-small and text-embedding-3-large, which are available via the Azure OpenAI Service. If you'd rather keep everything on-premises or avoid API costs, open-source alternatives like sentence-transformers are widely used. The right choice of embedding model depends on domain, language and document types.
Most importantly - the model used to embed your documents at index time must be the same one used to embed queries at retrieval time. Using different models can produce incompatible embeddings for the same phrase, resulting in a completely different vector.
These handle storing and querying your embeddings. As mentioned above, Azure AI Search is a managed service that supports keyword, vector, and hybrid search out of the box - making it a convenient choice for Azure-based workloads. But, depending on your technology stack there are a lot of options out there (Pinecone, Weaviate, Qdrant, etc.).
Orchestration frameworks like LangChain, LlamaIndex, and Microsoft's Semantic Kernel provide higher-level abstractions for building RAG pipelines. Rather than writing the plumbing yourself (embed the query, query the index, format the results, build the prompt), these frameworks offer pre-built components for each step that can be wired together and swapped out. They also tend to integrate with a wide range of data sources and model providers, which makes it easier to experiment with different retrieval strategies without rewriting your whole pipeline.
Here are some of the main challenges to think about when designing a retrieval system:
A retrieval system might return ten documents, but if the most relevant one is ranked eighth, you may be passing less relevant information into the generation step. Hybrid search and re-ranking models (which apply a second, more expensive scoring pass over the top results - providing a more accurate rank) can both help here.
Chunking has a large impact on quality. Chunks that are too small may lack enough context to be useful, whilst chunks that are too large may dilute the relevant signal with noise. There's no universally correct answer - it depends on your documents, your embedding model, and the kinds of questions users will ask. Experimenting with different chunk sizes and overlap amounts is usually necessary to get the best results.
If your underlying data changes frequently, you need to think about data freshness. If documents in your index are updated or deleted, the index needs to reflect that - particularly in regulated domains where accuracy is critical.
If you are working at scale, latency can become a concern. Vector similarity search over millions of embeddings - API calls to generate the embeddings, then the search itself, combined with a keyword search and re-ranking, adds up quickly.
Most production systems use "approximate nearest neighbour" algorithms - which instead of performing exact matching on all embeddings in the index, use an index which is organised in vector space to discount results which are likely to be irrelevant. This trades some accuracy, but produces much faster results. Solutions also often cache frequently-asked queries where possible.
As in any data system security and access control are important to get right. If your data store contains documents that different users should have different levels of access to, retrieval must respect those boundaries. Returning a document in the retrieved context that the user isn't authorised to see (even if they can't see the document directly) could leak sensitive information in the generation step. The safest approach is to apply access filters at query time, so only documents the user is permitted to see can ever be retrieved.
The advantage of RAG here is that, if you instead just trained a model on all the data, there would be no way to enforce different security boundaries for different users. The ability to use fine-grained access control is one of the huge strengths of a RAG architecture.
Retrieval is the foundation that the rest of RAG is built on.
The key message is that there's no single "correct" retrieval strategy - the right approach depends on your data, your users, and the kinds of questions being asked.
Structured database queries work well for precise, known criteria. Keyword search is fast and auditable. Vector search handles semantic similarity where keywords fall short. And hybrid approaches combine the strengths of both.
In the next post, I'll look at the augmentation step - how the retrieved documents are prepared, formatted, and injected into the prompt to give the LLM the context it needs to generate a useful response.
In a previous post, I discussed whether sandboxes were necessary for shared AI agents deployed in a corporate environment and concluded that, so long as the tools the agents use are secure, sandboxes are unnecessary.
However, local agents are a different story. Local agents are deployed on a developer's machine and run arbitrary prompts, potentially with full access to the local environment. This makes it easy to accidentally or maliciously delete files, exfiltrate secrets, or otherwise compromise the local environment or any remote environment the local agent has access to.
In this post, I'll discuss an approach to local sandboxes that contain the local agent while still providing much of the convenience when working in an IDE.
You can find the final Vagrantfile from GitHub.
If you have used any coding agents, you will be familiar with the confirmation prompts that are presented when the agent makes potentially destructive changes or may access sensitive information. While AI agents are getting better at presenting only those prompts that genuinely require confirmation, these confirmations are still presented far too often. If your security processes demand the patience of a Vulcan and the attention to detail of a leet-coder, you don't have a security process. Demanding that developers approve each confirmation (especially when the confirmations are as obtuse as Yes, and don’t ask again for: awk '{print length($0), $0}' - what does that even mean?) has more in common with social engineering attacks like MFA fatigue than it does with a practical security process.
A better solution is to run AI agents in a sandboxed environment that limits their access via policies. This way, trusted prompts can be run without confirmation, with the assurance that the agent cannot access sensitive information or perform destructive actions.
The goal of the sandbox presented in this post is to:
Non-goals are:
We'll focus on running Claude Code in the sandbox, but the same approach applies to other local AI agents.
To achieve these goals, the sandbox environment will be created as a Vagrant box.
You can install the vagrant CLI from the Vagrant website.
MacOS and Parallels users will need to install the Parallels provider.
Linux users will need to install the libvirt provider.
Windows users will need to use the VirtualBox provider or the Hyper-V provider.
The sandbox is coded in a Vagrantfile that defines how the virtual machine is created and configured.
We'll make use of the shellwords library to escape shell arguments when creating the sandbox:
require "shellwords"
Vagrant requires a user with sudo privileges to execute the provisioning scripts. This user is called vagrant by default, and is present in most base Vagrant boxes.
So we need to create a restricted user for the AI agent. This user is named claude and has UID 1001. The home directory for this user is /home/claude, and the runtime directory is /run/user/1001:
AGENT_USER = "claude"
AGENT_UID = 1001
AGENT_HOME = "/home/#{AGENT_USER}"
AGENT_RUNTIME_DIR = "/run/user/#{AGENT_UID}"
A challenge with the sandbox environment is that directories mounted from the host machine will appear in a different path. For example, project repositories mounted from ~/Code on the host machine will appear in /home/claude/Code in the sandbox. We need to track the directory the files are mounted from so we can instruct the AI agent to translate paths reported by the IDE to the correct paths in the sandbox. The host home directory is defined as follows:
HOST_HOME = File.expand_path("~")
We start a Vagrant configuration block and define the base box to use. In this case, we use the bento/ubuntu-24.04 box, which is a minimal Ubuntu 24.04 image:
Vagrant.configure("2") do |config|
config.vm.box = "bento/ubuntu-24.04"
Windows users will need to select a different base box, as the bento/ubuntu-24.04 box is not compatible with Hyper-V. We use the boxen/ubuntu-24.04 box for Hyper-V:
config.vm.provider "hyperv" do |hv, override|
override.vm.box = "boxen/ubuntu-24.04"
end
:::div{.info} The public Vagrant Cloud boxes are being deprecated. You will need to eventually source the base boxes from your own file storage. :::
Vagrant automatically mounts the current directory to /vagrant in the virtual machine. We disable this mount as we will only be exposing the ~/Code directory to the sandbox, and we don't want the AI agent to have access to unexpected files:
config.vm.synced_folder ".", "/vagrant", disabled: true
We mount the ~/Code directory to /home/claude/Code in the sandbox, using NFS for better performance. We also disable UDP for NFS, as it can cause issues with some network configurations:
config.vm.synced_folder File.expand_path("~/Code"), "#{AGENT_HOME}/Code",
type: "nfs",
nfs_version: 3,
nfs_udp: false,
mount_options: ["actimeo=1", "nolock", "tcp", "rw", "fsc"]
When creating a virtual machine on macOS and Parallels or Windows and Hyper-V, the in-built shared folder implementation is more stable than NFS. We can override the NFS mount and use the native mount options:
config.vm.provider "parallels" do |prl, override|
override.vm.synced_folder File.expand_path("~/Code"), "#{AGENT_HOME}/Code",
type: nil,
mount_options: ["share", "rw"]
end
config.vm.provider "hyperv" do |hv, override|
override.vm.synced_folder File.expand_path("~/Code"), "#{AGENT_HOME}/Code",
type: "smb",
mount_options: ["rw", "uid=#{AGENT_UID}", "gid=#{AGENT_UID}", "mfsymlinks"]
end
The sandbox is configured with 4GB of memory and 6 CPUs. This is sufficient for most local AI agents, but you can adjust these values as needed:
config.vm.provider "parallels" do |prl|
prl.memory = 4096
prl.cpus = 6
end
config.vm.provider "libvirt" do |lv|
lv.memory = 4096
lv.cpus = 6
end
config.vm.provider "virtualbox" do |vb|
vb.memory = 4096
vb.cpus = 6
end
config.vm.provider "hyperv" do |hv|
hv.maxmemory = 4096
hv.cpus = 6
end
The AI agent needs an API key to authenticate with Claude. We fetch the API key from the host environment and expose it in a file called /etc/anthropic_api_key.env in the sandbox. This file is owned by root and has permissions set to 600, so only root can read it. The AI agent will be able to read this file, but it will not be able to write to it or delete it:
anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY') do
raise "ANTHROPIC_API_KEY is not set on the host. " \
"Export it before running vagrant up:\n" \
" export ANTHROPIC_API_KEY='your-key-here'"
end
config.vm.provision "shell",
run: "always",
upload_path: "/home/vagrant/vagrant-shell",
inline: <<-SHELL
set -euo pipefail
install -o root -g root -m 600 /dev/null /etc/anthropic_api_key.env
echo "export ANTHROPIC_API_KEY='#{anthropic_api_key}'" > /etc/anthropic_api_key.env
SHELL
:::div{.info} A common challenge in building sandbox environments is exposing secrets required to support the AI agent or MCP servers. While we'll make efforts to hide these credentials from the AI agent, the agent can still exfiltrate them, as we'll see later. This is where we are forced to trade off between security and convenience. This sandbox makes a conscious decision to prioritize convenience. :::
The Claude configuration is copied from the host machine to the sandbox. This allows the AI agent to use the same configuration as the host machine, while still being restricted to the sandbox environment:
config.vm.provision "file",
source: "~/.claude.json",
destination: "/home/vagrant/claude.json.upload"
We now start building the sandbox environment. This is done in a shell provisioner that runs as root:
config.vm.provision "shell",
upload_path: "/home/vagrant/vagrant-shell",
inline: <<-SHELL
set -euo pipefail
The claude user is created with the specified UID, home directory, and shell. The -M option prevents the creation of a home directory, as we will construct this manually:
useradd \
--uid #{AGENT_UID} \
--home-dir #{AGENT_HOME} \
--shell /bin/bash \
-M #{AGENT_USER}
The home directory for the claude user is created with the correct ownership and permissions. The -d option creates the directory, the -o and -g options set the owner and group to the claude user, and the -m option sets the permissions to 750, which allows the owner to read, write, and execute, while allowing the group to read and execute, but not write:
install -d -o #{AGENT_USER} -g #{AGENT_USER} -m 750 #{AGENT_HOME}
Launching the AI agent requires that the ANTHROPIC_API_KEY environment variable be set. We create a launcher script that sets this environment variable and then launches the AI agent as the claude user. The launcher script is owned by root and has permissions set to 755, so it can be executed by any user. This is how we prevent the claude user from reading the contents of the /etc/anthropic_api_key.env file, while still allowing the AI agent to authenticate itself with the ANTHROPIC_API_KEY environment variable:
cat > /usr/local/sbin/claude-agent <<'LAUNCHER'
#!/bin/bash
set -euo pipefail
. /etc/anthropic_api_key.env
# --dir is the directory the agent should start in, given relative to the synced
# tree. claude.sh sends the directory it was called from on the host, which is the
# same tree under a different prefix, so the relative path is all that travels.
# Optional: without it the agent starts at the root, which is what a bare
# `sudo /usr/local/sbin/claude-agent` in the guest still does. Anything left on the
# command line afterwards is passed through to claude untouched.
code_root=#{AGENT_HOME}/Code
target=$code_root
rel=
if [ "${1:-}" = --dir ]; then
if [ "$#" -lt 2 ]; then
echo "claude-agent: --dir needs a value" >&2
exit 2
fi
rel=$2
shift 2
fi
# Validated, but never fatal: a --dir that cannot be honoured should still get you a
# working agent at the root rather than no agent at all. The one thing worth being
# strict about is the shape — --dir names a location inside the synced tree by
# construction, so an absolute path or a .. component is a caller bug, and a caller
# bug that silently starts the agent somewhere outside the tree is worth refusing.
case $rel in
""|.)
;;
/*)
echo "claude-agent: --dir must be relative to $code_root, ignoring '$rel'" >&2
;;
..|../*|*/..|*/../*)
echo "claude-agent: --dir must stay inside $code_root, ignoring '$rel'" >&2
;;
*)
if [ -d "$code_root/$rel" ]; then
target=$code_root/$rel
else
echo "claude-agent: $code_root/$rel does not exist, starting in $code_root" >&2
fi
;;
esac
# The target is handed to the inner shell as a positional argument rather than
# spliced into its script. That script is a single-quoted string, so a path pasted
# into it would be parsed by that shell as code.
exec sudo -u #{AGENT_USER} -H env ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
bash -lc 'cd "$1" || exit 1; shift; exec claude "$@"' claude "$target" "$@"
LAUNCHER
chown root:root /usr/local/sbin/claude-agent
chmod 755 /usr/local/sbin/claude-agent
The claude user's home directory is currently empty. We copy the contents of /etc/skel to the claude user's home directory. This includes files like .bashrc, .profile, and .bash_logout, which are used to configure the shell environment for the user:
for skel in /etc/skel/.[!.]*; do
[ -f "$skel" ] || continue
install -o #{AGENT_USER} -g #{AGENT_USER} -m 644 \
"$skel" "#{AGENT_HOME}/$(basename "$skel")"
done
The Claude configuration file is copied to the claude user's home directory. The file is owned by the claude user and has permissions set to 600, so only the owner can read and write to the file. The original file in /home/vagrant/claude.json.upload is then cleaned up:
install -o #{AGENT_USER} -g #{AGENT_USER} -m 600 \
/home/vagrant/claude.json.upload #{AGENT_HOME}/.claude.json
rm -f /home/vagrant/claude.json.upload
We now configure the Claude Code managed settings. These settings are stored in /etc/claude-code/managed-settings.json, which is owned by root and has permissions set to 444, so it can be read by any user, but not written to.
The permissions deny the ability to commit or add files to a Git repository, as well as the ability to execute certain commands in IntelliJ. The settings also disable sideload flags and restrict the AI agent's access to certain environment variables and files. It also excludes docker commands from the sandbox, which is required to allow the AI agent to run Docker commands:
mkdir -p /etc/claude-code
chown root:root /etc/claude-code
chmod 755 /etc/claude-code
cat > /etc/claude-code/managed-settings.json <<'JSON'
{
"permissions": {
"deny": [
"mcp__intellij__execute_terminal_command",
"mcp__intellij__execute_run_configuration",
"mcp__intellij__execute_tool",
"mcp__intellij__build_project",
"mcp__intellij__run_inspection_kts",
"mcp__intellij__validate_inspection_kts",
"mcp__intellij__execute_sql_query",
"mcp__intellij__xdebug_start_debugger_session",
"mcp__intellij__xdebug_control_session",
"mcp__intellij__xdebug_evaluate_expression",
"mcp__intellij__xdebug_set_variable",
"mcp__intellij__xdebug_set_breakpoint",
"mcp__intellij__xdebug_remove_breakpoint",
"mcp__intellij__xdebug_run_to_line",
"mcp__intellij__apply_patch",
"mcp__intellij__create_new_file",
"mcp__intellij__reformat_file",
"mcp__intellij__rename_refactoring",
"mcp__intellij__create_database_connection",
"mcp__intellij__edit_database_connection",
"mcp__intellij__test_database_connection",
"Bash(git add)",
"Bash(git add:*)",
"Bash(git commit)",
"Bash(git commit:*)"
]
},
"allowManagedPermissionRulesOnly": true,
"allowManagedHooksOnly": true,
"disableSideloadFlags": true,
"env": {
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "0"
},
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"excludedCommands": [
"docker *"
],
"allowManagedReadPathsOnly": true,
"filesystem": {
"denyRead": [
"/etc/*.env",
"#{AGENT_HOME}/.claude.json"
],
"denyWrite": [
"#{AGENT_HOME}/.claude.json",
"#{AGENT_HOME}/.claude/settings*.json",
"#{AGENT_HOME}/.claude/CLAUDE.md",
"#{AGENT_HOME}/Code/.claude/settings*.json"
]
},
"credentials": {
"files": [
{ "path": "/etc/anthropic_api_key.env", "mode": "deny" },
{ "path": "/etc/github_copilot_token.env", "mode": "deny" },
{ "path": "#{AGENT_HOME}/.claude/settings.json", "mode": "deny" }
],
"envVars": [
{ "name": "ANTHROPIC_API_KEY", "mode": "deny" }
]
}
}
}
JSON
chown root:root /etc/claude-code/managed-settings.json
chmod 444 /etc/claude-code/managed-settings.json
:::div{.info} Again, we see a trade-off between security and convenience, as we mostly trust the IntelliJ MCP server. This MCP server is powerful and grants extensive access. Some tools have been denied, but the AI agent still has a broad collection of tools to use. :::
The Claude user settings are defined in /home/claude/.claude/settings.json, effectively disabling all security prompts:
mkdir -p #{AGENT_HOME}/.claude
cat > #{AGENT_HOME}/.claude/settings.json <<'JSON'
{
"skipDangerousModePermissionPrompt": true,
"acceptEdits": true,
"permissions": {
"defaultMode": "bypassPermissions"
},
"sandbox": {
"autoAllowBashIfSandboxed": true
}
}
JSON
Custom instructions are provided to the AI agent in a file called CLAUDE.md. This file is owned by the claude user and has permissions set to 644, so it can be read by any user but written to only by the owner. The instructions explain how to translate paths from the host machine to the sandbox environment, and how to use guest paths for tool calls:
cat > #{AGENT_HOME}/.claude/CLAUDE.md <<'MARKDOWN'
# Filesystem paths in this sandbox
You are running inside a Vagrant guest VM. The user, their IDE, and their
terminal are on the *host* machine. The host directory `#{HOST_HOME}/Code` is
synced to `#{AGENT_HOME}/Code` in this guest — same files, different prefix.
Any path that reaches you from the host side uses the host prefix and is NOT
valid here. This includes:
- the path of the file currently open in the user's IDE
- paths in IDE diagnostics, selections, or attached editor context
- paths the user types or pastes, and paths in output copied from the host
## Translate before every tool call
Rewrite the prefix, keep the rest of the path unchanged:
| Host path | Guest path to use |
| --- | --- |
| `#{HOST_HOME}/Code/<rest>` | `#{AGENT_HOME}/Code/<rest>` |
| `~/Code/<rest>` | `#{AGENT_HOME}/Code/<rest>` |
| `#{HOST_HOME}/<rest>` (outside `Code`) | not available in this sandbox |
For example, if the IDE reports the open file as
`#{HOST_HOME}/Code/MyProject/src/main.ts`, read and edit
`#{AGENT_HOME}/Code/MyProject/src/main.ts`.
Only `~/Code` is synced. If a host path falls outside it, do not invent a guest
equivalent and do not create the directory to make the path resolve — say the
file is not mounted into the sandbox and ask the user how to proceed.
## Translating back
Use guest paths for every tool call, and when you quote a path in your answer.
The exception is when you are telling the user which file to open on the host
(so their IDE can resolve it) — give the `#{HOST_HOME}/...` form there, and say
which side of the mapping the path belongs to.
The synced folder is mounted read-write, so edits you make under
`#{AGENT_HOME}/Code` appear on the host immediately. These are the user's real
working files, not a throwaway copy — treat them accordingly.
# The account you are running as
You are the `#{AGENT_USER}` user. It is unprivileged on purpose: it has no sudo, no
password, and no membership of the `sudo`, `docker`, `lxd` or `adm` groups. The
`vagrant` account, and its home directory, are not yours to read or write.
So: install nothing system-wide. `apt-get`, `npm install -g` and anything else
needing root will fail, and that is the configuration working, not a problem to
route around. Use a venv, `npm install` into the project, or the rootless Docker
daemon already running for you (`DOCKER_HOST` is set in your environment). If a
task genuinely needs root in this VM, say so and ask the user to run it from the
host with `vagrant ssh`.
MARKDOWN
chown -R #{AGENT_USER}:#{AGENT_USER} #{AGENT_HOME}/.claude
chown root:root #{AGENT_HOME}/.claude/settings.json #{AGENT_HOME}/.claude/CLAUDE.md
chmod 444 #{AGENT_HOME}/.claude/settings.json
chmod 444 #{AGENT_HOME}/.claude/CLAUDE.md
Claude expects to find a .mcp.json file that marks the project's root. We create an empty .mcp.json file in the sandbox home directory, owned by root and with permissions set to 444, so it can be read by any user, but not written to:
touch /home/.mcp.json
chown root:root /home/.mcp.json
chmod 444 /home/.mcp.json
The OS is updated, and a set of tools is installed that the AI agent can use. These tools are installed system-wide, but the claude user does not have permission to install additional tools:
apt-get update -y
apt-get upgrade -y
apt-get install -y \
auditd \
binfmt-support \
build-essential \
curl \
dbus-user-session \
fuse-overlayfs \
git \
jq \
python3 \
python3-pip \
python3-venv \
qemu-user-static \
screen \
slirp4netns \
uidmap \
unzip \
ufw \
btop \
bubblewrap \
socat
Docker is installed in rootless mode, so the claude user can run Docker commands without needing to use sudo. The Docker daemon is launched automatically when the sandbox is started, and the DOCKER_HOST environment variable is set to point to the rootless Docker daemon:
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| dd of=/etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-ce-rootless-extras
The root Docker daemon is disabled and masked, so it cannot be started by the claude user. The docker.sock file is removed, so the claude user cannot connect to the Docker daemon:
systemctl disable --now docker.service docker.socket containerd.service || true
systemctl mask docker.service docker.socket
rm -f /run/docker.sock
Rootless Docker requires a range of subuids and subgids to be assigned to the claude user. We check if the claude user has been assigned a range of subuids and subgids, and if not, we assign the range 165536-231071:
grep -q "^#{AGENT_USER}:" /etc/subuid || usermod --add-subuids 165536-231071 #{AGENT_USER}
grep -q "^#{AGENT_USER}:" /etc/subgid || usermod --add-subgids 165536-231071 #{AGENT_USER}
The rootless Docker daemon runs as a systemd --user unit, so the claude user needs a user manager that survives the end of the SSH session that started it. Enabling lingering provides one, keeping the manager running at boot with nobody logged in. It is also what creates the XDG_RUNTIME_DIR holding the session bus that the setup tool in the next step needs. loginctl returns before that directory appears, so we poll for it and fail loudly if it never shows up, rather than letting the next step fail with an unrelated-looking dbus error:
loginctl enable-linger #{AGENT_USER}
for _ in $(seq 1 30); do [ -d #{AGENT_RUNTIME_DIR} ] && break; sleep 1; done
[ -d #{AGENT_RUNTIME_DIR} ] || { echo "XDG_RUNTIME_DIR for #{AGENT_USER} never appeared"; exit 1; }
Rootless Docker is installed, and the Docker daemon is started as the claude user:
sudo -u #{AGENT_USER} -H env \
XDG_RUNTIME_DIR=#{AGENT_RUNTIME_DIR} \
DBUS_SESSION_BUS_ADDRESS=unix:path=#{AGENT_RUNTIME_DIR}/bus \
PATH=/usr/bin:/usr/sbin:/bin:/sbin \
dockerd-rootless-setuptool.sh install
sudo -u #{AGENT_USER} -H env \
XDG_RUNTIME_DIR=#{AGENT_RUNTIME_DIR} \
DBUS_SESSION_BUS_ADDRESS=unix:path=#{AGENT_RUNTIME_DIR}/bus \
systemctl --user enable --now docker
Environment variables are set for the claude user to point to the rootless Docker daemon. This is done by creating a file in /etc/profile.d that sets the XDG_RUNTIME_DIR and DOCKER_HOST environment variables when the claude user logs in:
cat > /etc/profile.d/docker-rootless.sh <<'PROFILE'
if [ "$(id -u)" = "#{AGENT_UID}" ]; then
export XDG_RUNTIME_DIR=#{AGENT_RUNTIME_DIR}
export DOCKER_HOST=unix://#{AGENT_RUNTIME_DIR}/docker.sock
fi
PROFILE
chown root:root /etc/profile.d/docker-rootless.sh
chmod 644 /etc/profile.d/docker-rootless.sh
Node.js is installed in the sandbox. This is done by adding the NodeSource repository and installing the nodejs package:
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt-get install -y nodejs
Claude Code is installed globally using npm. This allows the claude user to run the claude command from anywhere in the sandbox:
npm install -g @anthropic-ai/claude-code
The Claude Code configuration copied from the host may point to files in the host's ~/Code directory, which may look like /Users/matthewcasperson/Code. We need to rewrite these paths to point to the sandbox's /home/claude/Code directory. This is done by reading the .claude.json file and replacing any occurrences of the host path with the guest path:
config.vm.provision "claude-mcp-paths",
type: "shell",
run: "always",
upload_path: "/home/vagrant/vagrant-shell",
inline: <<-SHELL
set -euo pipefail
command -v jq >/dev/null || { echo "jq is not installed yet; run the main provisioner first"; exit 1; }
config=#{AGENT_HOME}/.claude.json
host_prefix=#{Shellwords.escape("#{HOST_HOME}/Code")}
guest_prefix=#{AGENT_HOME}/Code
[ -s "$config" ] || { echo "no $config to rewrite"; exit 0; }
jq -e . "$config" >/dev/null 2>&1 || { echo "$config is not valid JSON; leaving it alone"; exit 0; }
tmp=$(mktemp "$config.XXXXXX")
jq --arg host "$host_prefix" --arg guest "$guest_prefix" '
def retarget: (. / $host) | join($guest);
walk(if type == "string" then retarget else . end)
| if (.projects | type) == "object" then
.projects = reduce (.projects | to_entries[]) as $e ({};
.[$e.key | retarget] = ((.[$e.key | retarget] // {}) + $e.value))
else . end
' "$config" > "$tmp"
chown #{AGENT_USER}:#{AGENT_USER} "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$config"
echo "rewrote MCP host paths: $host_prefix -> $guest_prefix"
SHELL
The Code directory is marked as a trusted workspace in the Claude configuration. This allows the AI agent to run without confirmation prompts when accessing files in this directory:
config.vm.provision "claude-trust",
type: "shell",
run: "always",
upload_path: "/home/vagrant/vagrant-shell",
inline: <<-SHELL
set -euo pipefail
command -v jq >/dev/null || { echo "jq is not installed yet; run the main provisioner first"; exit 1; }
config=#{AGENT_HOME}/.claude.json
[ -s "$config" ] || install -o #{AGENT_USER} -g #{AGENT_USER} -m 600 /dev/null "$config"
jq -e . "$config" >/dev/null 2>&1 || printf '{}' > "$config"
tmp=$(mktemp "$config.XXXXXX")
jq '.projects["#{AGENT_HOME}/Code"] =
(.projects["#{AGENT_HOME}/Code"] // {}) + {"hasTrustDialogAccepted": true}' \
"$config" > "$tmp"
chown #{AGENT_USER}:#{AGENT_USER} "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$config"
echo "trusted workspace: #{AGENT_HOME}/Code"
SHELL
An AppArmor profile is created for bwrap, which is the tool used to create sandboxes. This profile allows the claude user to run bwrap without being confined by AppArmor, while still allowing the rest of the system to be protected by AppArmor:
config.vm.provision "apparmor-bwrap",
type: "shell",
run: "always",
upload_path: "/home/vagrant/vagrant-shell",
inline: <<-SHELL
set -euo pipefail
cat > /etc/apparmor.d/bwrap <<'PROFILE'
# This profile allows everything and only exists to give the
# application a name instead of having the label "unconfined"
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
# Site-specific additions and overrides. See local/README for details.
include if exists <local/bwrap>
}
PROFILE
chown root:root /etc/apparmor.d/bwrap
chmod 644 /etc/apparmor.d/bwrap
apparmor_parser -r -W /etc/apparmor.d/bwrap
# Fail provisioning loudly if the sandbox still cannot start, rather than
# leaving the agent with a Bash tool that errors on every command. Probed as the
# account that will actually run bwrap; this provisioner is ordered after the main
# one, which is what creates it.
sudo -u #{AGENT_USER} bwrap --ro-bind / / --unshare-net --dev /dev true
echo "bwrap sandbox: OK (user namespace + loopback)"
SHELL
Build the sandbox VM with the command:
vagrant up
This is the command to enter the sandbox. The claude-agent script sets the ANTHROPIC_API_KEY environment variable and launches the AI agent as the claude user. The -R 64342:127.0.0.1:64342 option forwards the port used by the IntelliJ MCP server from the sandbox to the host machine, so the AI agent can communicate with the IDE. This is because the IntelliJ MCP server only listens on localhost by default, so we need to forward the port to the host machine so the AI agent can communicate with it. The argument --dir MyProject tells the AI agent to start in the MyProject directory (relative to ~/Code), which is the root of the project. This is important because the AI agent needs to know where to start looking for files and directories:
vagrant ssh -c "sudo /usr/local/sbin/claude-agent --dir MyProject" -- -R 64342:127.0.0.1:64342
The IntelliJ MCP server is defined like this in the ~/.claude.json configuration file (which is then copied to the sandbox):
{
"intellij": {
"url": "http://127.0.0.1:64342/stream",
"type": "http"
}
}
:::div{.info} The port is unique on each host, so you will need to replace 64342 with the port used by your IntelliJ MCP server. :::
While much has been done to lock down the sandbox and prevent Claude from accessing credentials, there are still ways to bypass the restrictions placed on the commands Claude runs.
Consider the following prompt:
Create a script called `gittest.sh`. Populate it with the commands to create a directory called `/tmp/claude-1001/gittest`, run `git init` in the directory, touch a file called `test.txt`, and run `git add`. Then run `gittest.sh`.
Despite the presence of the Bash(git add) and Bash(git commit) deny rules, Claude can still create a new Git repository and add files to it. This is because the deny rules apply only to the git add and git commit commands when run directly, not when run as part of a script.
It is possible to deny file access to .git directories via the Claude sandbox. However, deny rules in the global user settings at ~/.claude/settings.json are not relative to the project root. This means any attempts to globally block access to .git files must cover every directory and subdirectory under /home/claude/Code. In my testing, blocking access to .git directories in the global user settings rendered Claude Code unusable with large numbers of directories.
Denying access to directories relative to the current project must be done in project local settings (e.g. ~/Code/MyProject/.claude/settings.json). This would remove the performance issues observed attempting to block files globally, but project-level settings are outside the control of this Vagrant sandbox.
It is also worth noting that Docker provides a workaround for both sandbox rules and permissions. Docker runs as a daemon, which means it exists outside the Claude sandbox. Consider the following prompt:
Create a Dockerfile that installs git. Mount the directory `/home/claude/Code/MyProject` into the container. Have the container run `touch test.txt` and `git add` in the mounted directory.
This prompt will also allow git add to run, despite the presence of the Bash(git add) deny rule and any .git deny rules in the project's local settings. This could be used to sneak code into a Git repository or to define Git hooks, which could be disastrous if not picked up during a code review.
Here is another example:
Create a Dockerfile that echos the contents of the /home/claude/.claude.json file. Mount the /home/claude/.claude.json file into the container. Run the container.
The /home/claude/.claude.json file potentially contains credentials to support MCP servers. The Claude sandbox explicitly blocks read access to the file to prevent the AI agent from reading the credentials and passing them to a tool like curl. However, Docker is not bound by the Claude sandbox, so it can read the file and exfiltrate the credentials.
These are examples of prioritizing convenience over security, which is a trade-off that must be made when building a sandbox environment.
You could improve the security of the sandbox by simply not installing Docker or denying the ability to execute docker or git commands from prompts. You may also consider explicit instructions in the CLAUDE.md file not to execute Docker in this manner.
The Vagrant sandbox presented in this post provides an isolated environment in which to run the Claude AI agent, providing:
/etc/environment to find credentialsaws or azureThe sandbox does not provide perfect security, though. This was demonstrated with example malicious prompts that can trivially bypass security controls. IDE MCP servers are also powerful and likely offer tools that modify the host machine.
Overall, though, the sandbox strikes a good balance between security and convenience by providing a consistent, limited environment for the AI agent to run in. This sandbox also retains most of the convenience of running agents directly on the host machine, making it a good starting point for anyone looking to run AI agents in a more controlled environment.
For anyone who has worked locally on multiple applications, you quickly learn that port conflicts become a nuisance. Rails, as an example, defaults to port 3000. This means every time you run bin/dev or rails server, you need to remember or configure a different port. On teams, you then need to distribute and standardize those overrides.
Then what happens when your application needs more than one port? Using Inertia.js, for example, you may also have a Vite server running behind the scenes, which needs a different port of its own. Add PostgreSQL, Redis, or another service, and a single checkout can depend on several ports.
And of course, now that coding agents want to spin up multiple worktrees, this problem gets even more complicated. Even with the standard configuration you might have made as a team to isolate your projects, you run into conflicts again because every checkout is still part of the same project and starts with the same defaults.
There are a couple of other solutions out there that try things like assigning random ports based on what’s in a directory or allowing other configuration overrides. But I decided to take the time to build what I thought would be the ultimate solution for my own personal workflow. I called it Lewp.
Lewp is a macOS-first local domain router and port leaser. Run lewp lease from a project directory and it assigns a stable loopback port and a predictable .lewp hostname. A checkout called feed-fix in the Reader project, for example, can be reached at https://feed-fix.reader.lewp. Lewp routes HTTP and HTTPS traffic for that hostname back to the port, while you continue to start the application yourself. After the one-time Lewp setup, HTTPS uses a locally trusted certificate, so there isn’t a browser warning to click through for every project.
The lease belongs to that project directory and survives restarts. Lewp will return the same hostname and port until you explicitly release them. It can also lease named ports that don’t need a hostname. A setup script can ask for separate vite, postgres, and redis ports, then pass those values to Vite or Docker Compose. That lets the application get its configuration from the CLI instead of hard-coding a new set of ports for every checkout.
That boundary is important. Lewp is not a process manager, and it is not specific to Rails. It doesn’t start Rails, Vite, Next, Docker, or anything else. It provides stable ports and local hostnames, then gets out of the way while your existing development commands run the application.
Lewp Reader is a reference application I built to show what this kind of worktree workflow can look like. It is a Rails application that builds a minimal RSS and Atom feed reader with Inertia.js, PostgreSQL, and Redis through Sidekiq. I chose it because it is a real multi-service application, not because Lewp requires Rails.
Once Lewp and the other development dependencies are installed, you can run bin/worktree feed-fix. The script creates or resumes the worktree, copies approved local configuration, installs dependencies, and provisions an isolated environment. Its setup leases four stable ports for Rails, Vite, PostgreSQL, and Redis. It writes those values into a local Mise environment, starts PostgreSQL and Redis in a dedicated Docker Compose project with their own volumes, and leaves Rails, Vite, and Sidekiq running on the host. Then you can enter the worktree, run bin/dev, and open https://feed-fix.reader.lewp.
Although Lewp Reader is a Rails application, the workspace orchestration is written in Bash rather than Ruby. That is purposeful. The Lewp commands and the isolation pattern can be adapted to another framework without first porting a set of Rails-specific tooling. The application preparation commands are the parts that need to change.
I also don’t expect you to copy all of this setup by hand. This is something you prompt your coding agent to set up. Point the agent at Lewp Reader, ask it to adapt the checked-in setup and teardown pattern to your application, and review what it changes. It should not become a major task for you.
On work projects, I also use the excellent DSLR to make quick copies of a staged database. For ease of use here, Lewp Reader seeds a new workspace database and preserves it when setup is run again. It also provides an explicit --reseed option when you want to reset only that workspace.
I prefer Stooges over worktrees, but I wanted to keep things simple and focused on Lewp here. The Lewp Reader repository includes an addendum with a copyable prompt and the constraints an agent should preserve when adapting the setup for Stooges.
Governance, Risk, and Compliance (GRC) is a crucial requirement in regulated industries. This is especially true with the rise of AI, as this APRA Letter to Industry on Artificial Intelligence demonstrates. Since deployments are where changes meet production systems, it is important to be able to define and enforce policies ensuring Octopus projects meet an organization's requirements.
Platform Hub provides the ability to define Open Policy Agent (OPA) policies, written in Rego, that can be applied to Octopus projects. In this post, you'll create a policy that enforces the presence of the Self-Support process template created in the previous post.
:::div{.hint} The Octopus AI Assistant will work with an on-premises Octopus instance, but it requires more configuration. The cloud-hosted version of Octopus doesn't need extra configuration. This means the cloud-hosted version is the easiest way to get started. :::
Use the instructions from the previous post to create the Web App project and then add the Self-Support process template to the project.
In this scenario, you'll define a process to enforce a policy requiring the Self-Support process template to be present in all projects. This will be an incremental process that first identifies noncompliant projects and then begins enforcing the policy.
The mock Git repository linked as part of the previous post contains a policy that enforces the presence of the Self-Support process template:
:img{ src="/blog/img/octo-easy-mode-22-policy/policy.png" alt="Self-Support policy" loading="lazy" }
Policies have two parts:
The default Scope Rego is this:
package self_support_exists
default evaluate := true
# The following are examples of available scoping options:
evaluate if {
# Scope evaluation by Environment name
# input.Environment.Name == "<environment-name>"
# Scope evaluation to Space Id
# input.Space.Id == "Spaces-1"
# Scope evaluation to multiple Spaces
# input.Project.Slug in ["<project-slug>", "<project-slug2>"]
}
The commented conditions need to be updated to reflect your local environment. The most common change is to define the space that the policy applies to. In this example, the policy applies to the custom space with the ID Spaces-1234:
package self_support_exists
default evaluate := true
# The following are examples of available scoping options:
evaluate if {
# Scope evaluation by Environment name
# input.Environment.Name == "<environment-name>"
# Scope evaluation to Space Id
input.Space.Id == "Spaces-1234"
# Scope evaluation to multiple Spaces
# input.Project.Slug in ["<project-slug>", "<project-slug2>"]
}
:::div{.warning} Changes committed to the mock Git repo are reset after a period of time. It is expected that the changes to the policy will be reverted. :::
The sample Conditions Rego is this:
package self_support_exists
# Default: Deny all deployments
default result := {"allowed": false}
# Allow: If a specific Process Template is used and not bypassed
result := {"allowed": true} if {
some step in input.Steps
# Ensure the step is derived from a Process Template
step.Source.Type == "Process Template"
# Target a specific template by its unique slug or ID
step.Source.SlugOrId == "self-support"
# Verify this specific step hasn't been added to the skipped list
not step.Id in input.SkippedSteps
# Verify the step is enabled
step.Enabled == true
}
This policy ensures that a step of type Process Template is present in the deployment process and linked to the Self-Support process template, as indicated by the slug self-support. The step must not be skipped and must be enabled; otherwise, the deployment will fail.
The easiest way to see the step types is to download the deployment process as JSON:
The resulting JSON blob can be quite large, but towards the end of the file, you will see code that looks like this:
"Actions": [
{
"Id": "fc95fd56-778c-42c6-ae35-fe611dfb4619",
"Name": "Run a Process Template",
"Slug": "run-a-process-template",
"ActionType": "Octopus.ProcessTemplate",
"Notes": null,
"IsDisabled": false,
"CanBeUsedForProjectVersioning": false,
"IsRequired": false,
"WorkerPoolId": null,
"Container": {
"Image": null,
"FeedId": null,
"GitUrl": null,
"Dockerfile": null
},
The ActionType property indicates the type of the step. In this example we can see this is a process template step. This value directly relates to the step.ActionType property in the rego schema.
In this case, however, the step.Source.Type == "Process Template" condition is defined to indicate that a process template must exist. This implies that the step must have an ActionType of Octopus.ProcessTemplate. The documentation provides the exact values for the Source.Type property.
You'll then see a section that looks like this:
"Properties": {
"SelfSupport.WorkerPool": "WorkerPools-7706",
"SelfSupport.Claude.ApiKey": "#{LibraryVariableSet.Claude.ApiKey}",
"SelfSupport.Octopus.ApiKey": "#{LibraryVariableSet.Octopus.ApiKey}",
"SelfSupport.GitHub.PAT": "#{LibraryVariableSet.GitHub.PAT}",
"SelfSupport.RunCondition": "#{if Octopus.Deployment.Error}True#{/if}",
"Octopus.Action.ProcessTemplate.Reference.Slug": "self-support",
"Octopus.Action.ProcessTemplate.Reference.VersionMask": "4.X"
},
The Octopus.Action.ProcessTemplate.Reference.Slug property indicates the slug of the process template that the step is associated with, and this is the value that is assigned to the step.Source.SlugOrId property in the Conditions Rego.
The policy has the Violation Action setting configured to Warning. This means that if the scope is met but the conditions are not, a warning will be added to the audit log. Leave this value as it is for now.
The policy must be published before it can be evaluated:
You will be asked to specify the policy version (or be forced to use version 1.0.0 if you are publishing it for the first time).
You can then configure the policy to be active or inactive.
An active policy will add a warning to the audit log, or fail the deployment (depending on the violation action), for any deployments that do not meet the policy.
An inactive policy is not considered when evaluating deployments. You can publish inactive policies to evaluate them against previous deployments without impacting any future deployments.
:::div{.hint} The published policy remains in effect even if the Git repo is reset. :::
The easiest way to fail the policy is to disable the Self-Support process template:
:img{ src="/blog/img/octo-easy-mode-22-policy/disable-step.png" alt="Disable step" loading="lazy" }
Create a release and deploy it. The deployment will succeed, but the audit log will show a warning that the policy was not met. You can filter the audit log by the event category Compliance Policy evaluated as non-compliant with warning outcome:
You can review these audit log entries to identify which projects need to have the Self-Support process template added to their deployment process. Crucially, you have not disrupted any deployments by setting the Violation Action to Warning.
Once you are satisfied that all projects have the Self-Support process template added to their deployment process, you can set the policy to block deployments. This will fail any project deployments that do not meet the policy.
Return to the Policies screen, edit the Self-Support exists policy, and change the Violation Action to Block:
:::div{.warning} The mock Git repository has likely reset itself at this point, so you will need to reapply any changes to the scope rego. :::
Commit the changes and publish a new version of the policy in active mode.
Now, when you deploy a new release, it will be blocked by the policy and fail:
You created a policy to ensure the Self-Support process template is present in all projects. You then published the policy in warning mode to identify non-compliant deployments without impacting them. Finally, you changed the policy to block mode to prevent future deployments that do not meet it.
To productionize the example, you can be notified directly when a policy is violated with subscriptions. A subscription responds to specific audit log events and then calls an external system like email, Slack, or an HTTP webhook.
You will also need to copy the policy to your own Git repo. Do this by saving the file self-support-exists.ocl, committing it to your own Git repo, and configuring the Platform Hub version control settings.
In this podcast, Michael Stiefel spoke to Tracy Bannon about the role of artificial intelligence in software and the attendant risks in the areas of security, software development, and society at large. While it might be reasonable to assume a certain amount of trust within a software ecosystem, the risks escalate when the boundary between two software ecosystems is crossed.
By Tracy Bannon