Module 7: Agent Security and Reliability

4. Trust boundaries: what the agent can do without permission

Description

By the end of this lesson you'll be able to precisely answer the question that decides whether an agent goes to production or not — what's the worst thing this system can do? — because you'll have the four concrete levers used to limit an agent's capability in n8n: the credential a tool runs with, the operation the node has enabled, which parameters the model can fill in and which are fixed, and which agent on the team has each tool connected. And you'll know how to write the permission matrix that makes that answer verifiable instead of a matter of opinion.

This matters because the previous three lessons all ended up in the same place. Lesson 2: the filter blocked two of four attacks, and the other two get defended further in. Lesson 3: the defense that genuinely changed the outcome wasn't filtering better but the hijacked agent not having the tool. That phrase — it didn't have the tool — is the only thing in this whole module that doesn't depend on the model deciding well. Time to turn it into a method.

Connection to the module, and the boundary with Module 4. This needs saying clearly because the title looks similar. In Module 4, lesson 5 you already worked on "tool contracts and trust boundaries," and there you learned two things this lesson takes as known and doesn't repeat: how to write a tool's contract — Name, Description, and each parameter with its own description via $fromAI() — so the model picks the right tool and calls it with the right data; and the business criterion for deciding which actions need human approval — the reversibility, financial impact, and customer-facing commitment table. All of that still applies and is today's prerequisite.

What this lesson adds is the other half, which wasn't right for Module 4: there the angle was design — the agent using well what it has — here the angle is security — what the agent can do even if it decides badly, even if it got hijacked, even if the contract you wrote didn't stop it. A contract is an instruction; today's is capabilities. Concretely: how a credential's privilege gets trimmed, how an operation's surface gets eliminated, how you decide what part of a call the model can decide and what part is yours, and how all of that gets split across Module 5's team of agents. Lesson 5 takes the last piece — what can't be trusted to anyone even with trimmed permissions — and puts a person in front of it.

The building's keys

Think of an office building with a well-thought-out key system. The cleaning crew has a key that opens the hallways, the bathrooms, and the meeting rooms, but not the locked offices or the server room. Whoever handles petty cash has the key to the drawer where the money is, and that key opens nothing else. The server room person can't get into accounting. And there's a master key that opens everything, that exists, and that sits in administration's safe because nobody carries it around "just in case."

Nobody designs that system out of distrust. It gets designed because keys get lost, because people rotate, because a mistake by someone with the master key has a different size than a mistake by someone with the hallway key. The principle is called least privilege and it says something very simple: every actor gets exactly the access their job needs, and not one bit more.

An AI agent is the strangest actor you've ever hired for your building: it works fast, doesn't get tired, and follows what it reads. Giving it the master key "so everything just works" is exactly the decision that turns a successful injection into a disaster instead of an annoyance.

Now, where are the keys in n8n? There's no "agent permissions" screen. There are four levers, in four different places, and it's worth knowing all of them because each one cuts off a different kind of damage.

Lever 1 — The credential. It's what the account can do on the destination system, independently of n8n. A Postgres user with GRANT SELECT on a view can't write no matter what the query asks. A Gmail account with read-only permissions can't send. It's the physical key: if it doesn't open the door, it doesn't matter how nicely it's asked.

Lever 2 — The node's operation. It's what subset of what the credential allows is actually available. The Gmail node has dozens of operations; the one you connect as a tool has one configured. A Gmail node with Get Many can't send emails even if its credential has send permission, because that node doesn't do that.

Lever 3 — Parameters: fixed versus $fromAI(). It's what part of the call the model decides. A to field with a literal value is yours; a to field with $fromAI() belongs to the model, and by transitivity, to whoever manages to influence the model. This is the finest lever and the one most people leave open without realizing it.

Lever 4 — The split between agents. It's which tools are connected to which agent's ai_tool port. It's lesson 3's lever, formalized: an agent can't call a tool it doesn't have connected, period.

The four combine. And there's a useful hierarchy: the lower you apply the limit, the harder it is to get around. A limit in the System Message gets bypassed with good text. A limit in $fromAI() requires the model to generate something you don't accept. A limit in the node's operation can't be bypassed from the chat in any way. And a limit in the credential can't be bypassed even if someone edits your workflow.

Worked example

Let's take a single TuTienda tool — lookup_order, the most innocent one in the system, the one that only checks orders — and look at it twice.

Insecure version. It's how it usually ends up when someone puts it together quickly to make it work:

# Node: Postgres Tool — Name: lookup_order
#
# Credential: postgres_main
#   user: n8n_app
#   # This user is the same one the company's other workflows use.
#   # It has read and write permissions over the entire public
#   # schema, because at some point someone needed a workflow to
#   # insert rows and it was faster this way.
#
# Operation: Execute Query
#
# Query: {{ $fromAI("sqlQuery",
#            "A SQL query to look up order information", "string") }}

It looks reasonable. It works very well: the agent builds whatever query it needs, and since it knows SQL, it solves cases you didn't anticipate. It's even elegant.

Now look at the same thing with the four levers in hand:

  • Credential: a user with read and write over the entire schema. The master key.
  • Operation: Execute Query — the widest operation that exists on that node. It isn't "read orders," it's "do whatever you want with the database."
  • Parameters: the model writes the complete query. Not an order_id: the entire statement.
  • Split: connected to order_specialist, which receives assignments derived from the customer's text.

What's the worst thing this tool can do? Anything the n8n_app user can do on that database. A SELECT * FROM customers. An UPDATE orders SET status = 'delivered'. A DELETE. And no sophisticated attack is needed: it's enough for the model, faced with a customer saying "I don't want that order anymore, delete it," to generate the statement it finds useful. Module 4's tool contract — a good Description saying "use it only to query" — greatly reduces the probability of that happening. It doesn't change at all what's possible.

Version with all four levers applied. First, what happens outside n8n, in the database:

-- Runs once, done by whoever administers the database.
-- It isn't part of the workflow.

-- 1. A view exposing ONLY what the agent needs to see.
--    Notice what's NOT there: the customer's email, their phone,
--    the payment method, the internal cost. The agent doesn't need
--    them to answer "where's my order?", so it doesn't see them.
CREATE VIEW agent_order_status AS
SELECT
    o.id              AS order_id,
    o.customer_id,
    o.status,
    o.created_at,
    o.shipped_at,
    o.carrier_tracking_code
FROM orders o;

-- 2. A dedicated user for the agent. Not reused from the other
--    workflows: if it ever needs revoking tomorrow, this gets
--    revoked and nothing else.
CREATE USER n8n_agent_ro WITH PASSWORD '...';

-- 3. Read-only, and only on the view. Not even on the orders
--    table itself: if someone adds a sensitive column to orders
--    tomorrow, the agent doesn't see it, because the view didn't change.
GRANT SELECT ON agent_order_status TO n8n_agent_ro;

-- 4. And explicitly nothing else.
REVOKE ALL ON SCHEMA public FROM n8n_agent_ro;
GRANT USAGE ON SCHEMA public TO n8n_agent_ro;

And now the node:

# Node: Postgres Tool — Name: lookup_order
#
# Credential: postgres_agent_readonly
#   user: n8n_agent_ro
#   # Lever 1: even if the query asked for a DELETE, the database
#   # rejects it. It isn't our rule, it's an engine permission.
#
# Operation: Select
#   # Lever 2: not Execute Query. The node's Select operation
#   # builds the query from fields, not from free text.
#   # The "write whatever statement you want" surface no longer exists.
#
# Table: agent_order_status
#   # Fixed. The model doesn't choose the table.
#
# Return All: false
# Limit: 5
#   # A customer asks about their orders, not the store's 40,000.
#   # If an injection asks for "get all," the ceiling is 5.
#
# WHERE conditions:
#   customer_id  =  {{ $('Chat Trigger').item.json.customer_id }}
#   # Lever 3, the important part: this field is NOT $fromAI().
#   # It comes from the identifier the channel already verified (the
#   # authenticated WhatsApp number, or the web chat session). No text
#   # the customer writes can change it, because it never passes
#   # through the model.
#
#   order_id     =  {{ $fromAI("orderId",
#                      "The order number the customer is asking about,
#                       as it appears in their message. Digits only.",
#                      "string") }}
#   # This one IS the model's, and it's right for it to be: it's a piece
#   # of data the customer provides about their own case. And since it
#   # coexists with the customer_id filter, asking for someone else's
#   # order returns nothing.

A continuity note about that customer_id. It's the same field you established in Module 6, lesson 7, when you built the architecture for an agent serving several channels: each channel adapter resolves the customer's identity against the identities table — with its verified_at column — and passes it to the core inside the input contract. If you built that architecture, the expression doesn't read from Chat Trigger but from the core's input node, something like {{ $('core_input').item.json.customer_id }}. What matters for this lesson is identical in both cases: that value comes from an identity the channel already verified, not from what the customer wrote in the message. And if your system doesn't genuinely verify identity yet — if customer_id comes from an email the person stated in the chat — then this lever is protecting nothing, and that's a channel problem worth closing before continuing.

What to expect. Run lesson 2's exercise 1 attack against this version — the customer asking for "the status and delivery address" of an order she claims is her mom's. The model gets talked into it perfectly; there's nothing in the text giving it away. It calls lookup_order with orderId: "4498". And the query that comes out is:

SELECT order_id, customer_id, status, created_at, shipped_at,
       carrier_tracking_code
FROM agent_order_status
WHERE customer_id = 'CUS-8842'   -- from the verified session
  AND order_id = '4498'
LIMIT 5;

Zero rows. Order 4498 doesn't belong to CUS-8842. The agent honestly responds it can't find that order associated with their account. And notice something else: even if it had returned the row, the delivery address isn't in the view. Two independent levers blocked the same request, and neither one consulted the model.

Compare the effort against the result. You wrote a six-column view, created a user, ran two GRANTs, and changed three fields on the node. Half an hour. In exchange, the answer to "what's the worst thing this tool can do?" went from "anything in the database" to "return up to five rows of order status for the customer who's already authenticated." That second sentence can be said in a meeting.

The four action levels

To split permissions you need a criterion, and Module 4's — reversibility, financial impact, customer-facing commitment — is still the right one. What we're adding today is turning it into levels, because a level can be mapped to a concrete configuration decision.

LevelWhat it isHow it gets controlledExample at TuTienda
L0 — ReadQueries data, changes nothingRead-only credential, trimmed view, Limit, filter by verified identitylookup_order, lookup_charge, search_knowledge_base
L1 — Reversible writeChanges something that can be undone at no costSpecific operation (no free query), fixed allowed columns, filter by identitycreate_ticket, marking a ticket "under review," open_dispute
L2 — Sensitive writeIrreversible, financial impact, or commits the companyMandatory human approval (lesson 5) + value cap + loggingissue_refund, cancelling an order, applying a discount
L3 — ForbiddenThe agent should never be able to do itThe tool doesn't get connected. There's no configuration, there's absenceDeleting rows, changing catalog prices, sending email to an arbitrary recipient

Notice the jump between L2 and L3, because that's where a system's security genuinely gets decided. L2 is "can, with permission." L3 is "can't." And the constant temptation is moving things from L3 to L2 because "it would be useful for it to be able to, and it goes through approval anyway." Sometimes it's correct. But every time you do it, you add one more action an injection can attempt, and you add one more approval message to a person who already gets several — which is exactly the fatigue problem you're going to see in lesson 5.

A practical criterion for deciding between L2 and L3: if you can't name the specific, frequent legitimate use case justifying that tool, it's L3. "Just in case" isn't a use case.

Worked example

TuTienda's complete permission matrix, as it would stand at the end of this module. This is a document, not a node — and it's the artifact you answer with when someone asks what your system can do.

┌─ PERMISSION MATRIX — TuTienda ─────────────────────────────────────┐
│                                                                   │
│ AGENT: triage_agent                     Channel: web + WhatsApp   │
│   Exposed to untrusted content: YES (customer's message)          │
│   ┌──────────────────────┬─────┬────────────────────────────────┐ │
│   │ Tool                 │ Lvl │ Control                        │ │
│   ├──────────────────────┼─────┼────────────────────────────────┤ │
│   │ order_specialist     │ —   │ AI Agent Tool (delegation)     │ │
│   │ billing_specialist   │ —   │ AI Agent Tool (delegation)     │ │
│   └──────────────────────┴─────┴────────────────────────────────┘ │
│   Sensitive data: NO · Output channel: NO                         │
│   → Doesn't meet the trifecta. Risk: wrong delegation.            │
│                                                                   │
│ AGENT: order_specialist                                            │
│   Exposed to untrusted content: YES (triage's assignment)         │
│   ┌──────────────────────┬─────┬────────────────────────────────┐ │
│   │ lookup_order         │ L0  │ read-only cred. on view        │ │
│   │                      │     │ agent_order_status; customer_id│ │
│   │                      │     │ from verified session; Limit 5 │ │
│   │ check_return_        │ L0  │ read-only cred.; only reads    │ │
│   │   eligibility        │     │ the policy and purchase date   │ │
│   │ create_ticket        │ L1  │ fixed Insert operation; fixed  │ │
│   │                      │     │ columns; customer_id from      │ │
│   │                      │     │ session                        │ │
│   └──────────────────────┴─────┴────────────────────────────────┘ │
│   Output channel: NO                                              │
│                                                                   │
│ AGENT: billing_specialist                                          │
│   Exposed to untrusted content: YES (triage's assignment)         │
│   ┌──────────────────────┬─────┬────────────────────────────────┐ │
│   │ lookup_charge        │ L0  │ read-only cred. on view         │ │
│   │                      │     │ agent_charges; customer_id from │ │
│   │                      │     │ verified session; Limit 10     │ │
│   │ open_dispute         │ L1  │ fixed Insert into disputes;    │ │
│   │                      │     │ reversible by the team          │ │
│   │ issue_refund         │ L2  │ mandatory HUMAN REVIEW          │ │
│   │                      │     │ (lesson 5) · cap $2,000 ·       │ │
│   │                      │     │ amount never > order total     │ │
│   └──────────────────────┴─────┴────────────────────────────────┘ │
│                                                                   │
│ AGENT: inbox_reader_agent               Trigger: Schedule 15 min  │
│   Exposed to untrusted content: YES (emails from anyone)          │
│   ┌──────────────────────┬─────┬────────────────────────────────┐ │
│   │ read_support_inbox   │ L0  │ sub-workflow: max 10 emails,   │ │
│   │                      │     │ 500-char trim, Sanitize,       │ │
│   │                      │     │ no $fromAI() parameters        │ │
│   └──────────────────────┴─────┴────────────────────────────────┘ │
│   Sensitive data: NO · Output channel: NO                         │
│   → Deliberately disarmed. Only returns a classification.         │
│                                                                   │
│ AGENT: ticket_agent                     Input: validated JSON     │
│   Exposed to untrusted content: NO                                 │
│   ┌──────────────────────┬─────┬────────────────────────────────┐ │
│   │ create_ticket        │ L1  │ fixed Insert                   │ │
│   │ lookup_customer      │ L0  │ read-only; Limit 1; customer_id│ │
│   │                      │     │ from validated JSON, not $fromAI│ │
│   │ notify_support_team  │ L1  │ Gmail Send; FIXED recipient     │ │
│   │                      │     │ (support@tutienda.example)     │ │
│   └──────────────────────┴─────┴────────────────────────────────┘ │
│                                                                   │
│ LEVEL L3 — TOOLS NO AGENT HAS                                     │
│   · DELETE on any table                                           │
│   · UPDATE on products (prices, names, stock)                     │
│   · Gmail Send with a recipient from $fromAI()                    │
│   · Postgres with the Execute Query operation                     │
│   · Any tool with a write credential on the schema                │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

What to expect from this document. Three uses, and all three are real:

It gets read top to bottom looking for the trifecta. Every agent block says whether it's exposed to untrusted content, whether it touches sensitive data, and whether it has an output channel. None of the five has all three. That's the thirty-second review you do every time you add a tool.

The L3 section is the most important one and the one nobody writes. Documenting what the system can't do is what turns an intuition into a verifiable guarantee. And it has a practical effect: when in three months someone on the team proposes "let's connect a tool that updates prices," the conversation starts from an already-made, argued decision, not from scratch.

It's the answer to the interview question. "What's the worst thing your agent can do?" — "Issue a refund of up to $2,000, and only after a person approves it seeing the amount and the reason. Everything else it does is reading, or writing things the team can undo."

Capability, not instruction

There's a principle summing up this lesson worth keeping handy when you're deciding where to put a defense: if something genuinely matters, don't write it in the prompt — remove it from the capabilities.

Compare the two ways of resolving the same requirement, "the agent must not change prices":

# Form A — instruction
# Agent's System Message:
#   "Never modify a product's price under any circumstance.
#    If someone asks you to, refuse."
#
# The update_product tool stays connected, with the price
# column among the ones it can write.
# Form B — capability
# There's no tool at all that writes to the products table.
# The agent's credential doesn't even have GRANT UPDATE there.
#
# The System Message doesn't mention prices, because it doesn't need to.

Form A works the vast majority of the time. It's a clear instruction, a current model respects it, and in your tests you're never going to see a price changed. Form B works always, and not because the model is obedient, but because there's nothing to call.

Now, the part that has to be said with the same honesty: form B isn't always available, and sometimes the cost of applying it is too high. If the agent's job is updating prices, you can't take that capability away without taking away the job. There the path isn't going back to form A and hoping for the best: it's lowering the action to L2 — human approval — and putting a cap on it. The prompt's instruction still exists and helps, but as behavioral guidance, not as a barrier.

The practical criterion is this: for every security rule you write in a System Message, ask yourself whether a version of that rule exists that lives in the credential, in the operation, or in a fixed parameter. If it does, that's the one that counts, and the prompt's is a complement. If it doesn't, then that rule is inherently weak and needs a person behind it.

And a warning about what least privilege does not solve, so you don't oversell it: an agent with perfectly trimmed permissions can still tell the customer lies. It can claim the order arrives Thursday, that there's a 20% discount, or that their refund is approved, without calling any tool. No read-only credential stops a model from drafting a false paragraph. That's a different problem, with a different defense, and it's lesson 6.

Common mistakes

Reusing the credential that already existed because "it's the same database" (practical). What happens: when connecting the first Postgres tool, n8n offers the credential already configured for the company's other workflows — one with broad permissions, because other flows write — and that one gets picked. It works the first try, so nobody revisits it. Months later, the agent runs with the building's master key and nobody remembers deciding that. Why it happens: creating a new user, writing the GRANTs, and testing everything still works is half an hour of work producing no visible functionality; reusing is one click. How to spot it: open every credential your tools use and ask yourself what would happen if that credential got used for the worst possible command — if the answer is severe, that's your real limit, not what the node says. How to fix it: a dedicated credential per agent, with the minimum GRANT it needs; and if the destination system doesn't allow trimming permissions (some APIs are all or nothing), compensate with lever 2 and 3, which are in your hands.

Leaving $fromAI() on a field defining scope or destination (practical). What happens: a query's limit field, an email's to, an operation's table, or a filter's customer_id end up with $fromAI() because "the model knows what to put." And it does — until someone suggests something else. A limit from $fromAI() turns "check the customer's order" into "get all 200 records" with one well-placed sentence, which is exactly step 3 of lesson 3's attack. Why it happens: $fromAI() is the right, natural mechanism for data the customer provides about their own case, and it's easy to apply it out of habit to every field on the node without telling apart which ones belong to that class. How to spot it: for every $fromAI() in your system, ask whether that value describes the customer's case (order number, an amount they mention, a date) or the operation's scope (how many, to whom, on what table, with what permission); the latter is never the model's. How to fix it: scope and destination get fixed with a literal value or with an expression reading an already-verified piece of channel data, like {{ $('Chat Trigger').item.json.customer_id }}.

Writing the permission matrix after building the system (conceptual). What happens: someone finishes the complete system and only at the end sits down to document who can do what. They discover inbox_triage_agent meets all three trifecta conditions, that two agents share the same broad credential, and that there's a $fromAI() on a destination field — and fixing it means splitting agents, redoing sub-workflows, and re-testing everything. Why it happens: the matrix looks like documentation, and documentation happens at the end. How to spot it: if adding a new tool didn't require opening any document to decide which agent to connect it to, you don't have a matrix. How to fix it: the matrix gets written alongside Module 5's role sheets, in the same design session, and every new tool gets added there before connecting it on the canvas — it's the cheap way to discover a tool turns an agent into the dangerous link.

Exercises

Exercise 1 — Trim a tool. TuTienda has this tool connected to billing_specialist. Apply the four levers and rewrite it.

# Node: Postgres Tool — Name: lookup_charge
# Credential: postgres_main  (user n8n_app, read and write
#                             over the entire public schema)
# Operation: Execute Query
# Query: {{ $fromAI("query", "SQL to find charges", "string") }}
See solution
-- Outside n8n, once:
CREATE VIEW agent_charges AS
SELECT
    c.id            AS charge_id,
    c.customer_id,
    c.order_id,
    c.amount,
    c.currency,
    c.charged_at,
    c.status
FROM charges c;
-- We don't include: the card's last digits, the gateway
-- identifier, or the payment token. The agent can answer
-- "what's this charge?" without seeing any of that.

CREATE USER n8n_billing_ro WITH PASSWORD '...';
GRANT SELECT ON agent_charges TO n8n_billing_ro;
REVOKE ALL ON SCHEMA public FROM n8n_billing_ro;
GRANT USAGE ON SCHEMA public TO n8n_billing_ro;
# Node: Postgres Tool — Name: lookup_charge
#
# Credential: postgres_billing_readonly   (user n8n_billing_ro)
#   # Lever 1
#
# Operation: Select
#   # Lever 2 — free SQL is over
#
# Table: agent_charges
# Return All: false
# Limit: 10
#
# WHERE conditions:
#   customer_id  =  {{ $('Chat Trigger').item.json.customer_id }}
#     # Lever 3 — from the session, not the model
#
#   charged_at  >=  {{ $fromAI("chargeDate",
#                       "The date of the charge the customer is asking
#                        about, in YYYY-MM-DD. If the customer gives no
#                        date, use today minus 90 days.",
#                       "string") }}
#     # This one IS the model's: it's a piece of the customer's case.
#     # And even if it returns a wide range, the customer_id filter
#     # and Limit 10 bound the damage.

What's the worst it can do now: return up to ten charges from the customer who's already authenticated, with no card data. That fits in one sentence, and that's the proof the trim went well.

Why it works: the four levers act in independent layers. Even if the model generated an absurd value for chargeDate, the customer_id filter doesn't come from it; even if someone managed to change customer_id, the view doesn't expose card data; and even if the query asked to write, the credential can't.

Exercise 2 — Classify and decide. For each action, assign a level (L0/L1/L2/L3), say which agent in TuTienda's system you'd connect it to (or none) and with what control:

(a) Checking how many units of a product are left in stock. (b) Subscribing the customer's email to the newsletter list. (c) Changing the shipping address of an order that hasn't shipped yet. (d) Sending an email with the case summary to an address the customer specifies. (e) Resending an already-issued invoice to the customer's registered email.

See solution

(a) L0. To order_specialist or a sales_specialist. Read-only credential on a catalog view, low Limit, product_id from $fromAI() — it's a piece of the customer's case and identifies nobody.

(b) L1. To sales_specialist. It's reversible (can be unsubscribed) and low impact. An important detail: the email does not come from $fromAI() but from the customer's verified record — if it came from the model, the agent could subscribe anyone, and that stops being L1.

(c) L1 or L2, depending on a fact the agent can verify. If the order is in pending status and hasn't shipped, it's reversible and cheap: L1, with the hard condition that the tool only accepts orders in that state — that gets fixed in the query itself, it's not asked of the model. If it already shipped, changing the address requires coordinating with the carrier: L2, human approval. The clean way to solve it is having the tool be update_shipping_address_if_pending and fail on its own when the state doesn't allow it.

(d) L3. Doesn't get connected. It's exactly lesson 3's exfiltration pattern: a recipient controllable from text turns any read tool into a leak. If the legitimate use case exists — "send the summary to my email" — it gets solved like (e).

(e) L1. To billing_specialist. The difference from (d) is total and worth seeing: the recipient is fixed by expression, {{ $json.customer_email }} read from the authenticated customer's record. The agent decides whether to send; it doesn't decide where to. Same apparent use case, completely different risk.

Why it works: (d) and (e) look like the same functionality from the customer's perspective — "send this to me by email" — and they're at opposite ends of the matrix. What separates them isn't what the tool does, it's what part of the call the model decides.

Exercise 3 — The agent that updates prices. A client asks you for an agent that adjusts catalog prices according to business rules they're going to give it over chat. Writing prices is L3 in your matrix. Design a solution that serves them without any agent having that direct capability, and say what you give up with your design.

See solution

The solution isn't connecting update_product_price with a very strict Description. It's splitting the action in two and putting a boundary in the middle:

# Agent: pricing_agent
#   Tools:
#     lookup_product_prices     L0  read-only on catalog view
#     propose_price_change      L1  INSERT into the
#                                   price_change_proposals table
#   Has NO tool that writes to products.
#
#   propose_price_change's output:
#     { product_id, current_price, proposed_price, reason,
#       status: "pending" }
#
#          ▼
#
# Separate workflow — NOT an agent:
#   Schedule Trigger (hourly)
#     └─► Postgres: SELECT from price_change_proposals WHERE pending
#     └─► Code: deterministic validations
#           · proposed_price > unit cost
#           · variation <= 15% from the current price
#           · the product exists and is active
#     └─► Slack: Send and Wait for Response  (lesson 5)
#           "Proposed change: SKU-4410 from $890 to $790 (-11%).
#            Reason: seasonal clearance. Approve?"
#     └─► [Approved] ─► Postgres: UPDATE products SET price = ...
#                       (a DIFFERENT credential, one the agent doesn't use)
#         [Rejected] ─► Postgres: UPDATE proposals SET status

The agent proposes; a deterministic workflow validates; a person approves; a credential the agent doesn't have executes. The price still gets updated, which is what the client asked for.

What you give up, said plainly:

  • Immediacy. The change doesn't happen in the conversation; it happens on the workflow's next run, after an approval. If the client expected "I tell the bot and the price changes," this isn't that.
  • Flexibility. The validations are code, not model judgment. A legitimate case the code didn't anticipate — a real 40% clearance — gets rejected and the code needs adjusting. That rigidity is the price of an injection not being able to get through either.
  • Simplicity. It's two workflows, an intermediate table, and an approval channel instead of one tool. It's more to maintain.

And what you gain: the answer to "what happens if someone hijacks the pricing agent?" is "they manage to insert a row into a proposals table nobody's going to approve."

Why it works: the pattern — proposing instead of executing, with deterministic validation and human approval in between — is what gets used for any L3 action the business genuinely needs. It doesn't turn L3 into L2 by decree; it builds the infrastructure that makes the action safe, and that infrastructure lives outside the agent.

Summary and next step

Least privilege in n8n gets applied with four levers, and it's worth keeping them in that order because they go from hardest to bypass to easiest: the credential a tool runs with, the operation the node has enabled, which parameters are fixed and which come from $fromAI(), and which tools are connected to which agent. On top of those levers sits a four-level classification — L0 read, L1 reversible write, L2 sensitive with approval, L3 not connected — and a permission matrix documenting, agent by agent, what it can do, and above all what it can't. The principle summing it up: if something genuinely matters, don't write it in the prompt, remove it from the capabilities.

Before moving on to lesson 5 you should be able to: name the four levers and say which one acts outside n8n; tell a legitimate $fromAI() — a piece of the customer's case — apart from a dangerous one — the operation's scope or destination; write your own matrix's L3 section, which is the one almost nobody writes; and answer in one sentence what the worst thing your system can do today is.

One piece remains. Everything today works by taking capabilities away from the agent, and there are actions the business genuinely needs that can't be taken away: issue_refund exists because TuTienda sometimes really does have to give money back. For those — the L2s — the answer isn't a finer permission, it's a person in the middle. Lesson 5 sets up that mechanism with n8n's two real ways to pause a workflow and wait for approval: human review on the agent's Tools connector, which you already saw from a distance in Module 4, and the send-and-wait-for-response operation on a channel node. With the detail that decides whether the mechanism helps or gets in the way: exactly what the approver sees.

Resources