---
title: 'File System and Terminal Access'
source: 'https://academia.sh/en/courses/ai-agents/file-system-and-terminal-access'
course: 'AI Agents'
language: en
updated: '2026-10-21T05:02:27+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Defines a tool's permission surface through the capability it declares and the path and command it touches."
    - 'Writes a permission gate placed in front of the executor and counts the stopped calls.'
    - 'Compares full, read-only, and allowlisted permission sets on the axes of completed tasks and stopped calls.'
    - 'Separates the correct answer from the completed task through the step with a side effect.'
---

# File System and Terminal Access

Counting permission restriction on the agent's file and command surface: twenty-four fictional surface tasks are run under three permission sets, and completed tasks and stopped calls are printed side by side. Under full permission 0.9583 of the tasks are completed and the gate stops no call; the read-only permission stops 71 calls but drops completed tasks to 0.5000; the allowlisted set stops 20 calls and stays at 0.8333, and all of its stops are gathered in a single task kind. Under read-only the correct answer is 0.6250 while completed tasks are 0.5000: in three tasks the answer was produced correctly but the write never happened. A stopped call also consumes a step; steps rise from 78 to 129. Although the totals stay the same in the two runs, two tasks swap places at the task level, so the measurement band is 0.0833 and the 0.1250 difference between full permission and the allowlist is above the band.

The previous lesson tied the code execution tool to a resource limit and counted how many calls the
limit stopped. What stopped there was **quantity**: memory, time, output length. The surface the agent
touches does not end there: the same loop reads files, writes files, and runs operating system commands.
In these three actions what stops is not quantity but **permission**.

The lesson gathers that permission in a single concept: the **permission surface**, which is the
capability a tool declares together with the path or command it touches. The principle of least
privilege was established in the Introduction to Cybersecurity course, and file permissions and
ownership were measured in the Introduction to Linux course; neither routine is repeated. The only
things counted are these: under three permission sets, how many tasks the agent finishes, how many calls
the gate stops, and who is billed for stopping.

- **TM31.** The code is a **simulator**; no real file system or runtime is called. The environment, the
  tools, and the tasks are **fictional**, seed **20260218**.
- **TM32.** The agent loop is the shared definition of the course: the share of picking the right tool
  falls with depth, and when the job is done the agent stops with a share of 0.88.
- **TM33. The permission surface is the field the tool declares:** each tool declares a **capability**
  (read, write, execute); the path or command it touches is read from inside the call. The fictional
  tree carries five paths, and the fictional terminal surface carries four commands.
- **TM34. The gate stands in front of the executor.** A call that does not pass never reaches the tool;
  it returns to the loop as a tool error and consumes a step. The three measured sets are **full**,
  **read-only**, and **allowlisted**.
- **TM35. Two conditions are required to call a task completed:** the answer must be correct and the
  step with a side effect in the sequence must have run. Looking only at the answer counts a task that
  did not write as finished.
- **TM36. The baseline is the full permission set** — the loop in which the gate stops nothing. The cost
  is **stopped calls, steps, and dropped tasks**.

## The Shared Loop and the Declaration of the Surface

The environment, the five basic tools, and the agent loop are the same throughout the course.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def tokenize(m):
    b = []
    for s in str(m).lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL environment: records of the measurement network
REGION = ["east", "west", "north", "south", "central"]
TYPE = ["residential", "commercial"]
SUBSCRIBERS = {}
for i in range(60):
    r = rng(SEED + 31 * i)
    previous = 10000 + int(r() * 80000)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)],
                                    "previous": previous, "current": previous + int(r() * 70)}
TIERS, ABOVE = ((10, 4), (25, 7), (40, 11)), 16
COMMERCIAL_FACTOR = 2
DOCUMENTS = {"TR01": "tarife basamaklari 10 25 40 metrekup ve ustu",
             "TR02": "isyeri abonesinde birim ucret iki katidir",
             "SY01": "endeks farki tuketimi verir sayac donusu 100000 eklenir",
             "AR01": "ariza kaydi acildiginda saha ekibi iki gun icinde gider",
             "KS01": "planli kesinti duyurusu bir hafta once yayimlanir"}

CALLS = [0]                              # tool call counter


def _charge(m3, type_):
    remaining, lower, total = m3, 0, 0
    for upper, unit in TIERS:
        band = min(remaining, upper - lower)
        if band <= 0:
            break
        total += band * unit
        remaining -= band
        lower = upper
    total += remaining * ABOVE
    return total * (COMMERCIAL_FACTOR if type_ == "commercial" else 1)


def t_read_meter(subscriber=None, **_):
    a = SUBSCRIBERS.get(subscriber)
    if a is None:
        return {"error": "no such subscriber"}
    return {"previous": a["previous"], "current": a["current"], "type": a["type"],
            "region": a["region"]}


def t_consumption(previous=None, current=None, **_):
    if previous is None or current is None:
        return {"error": "missing field"}
    f = current - previous
    return {"m3": f + 100000 if f < 0 else f}


def t_tariff(m3=None, type="residential", **_):
    if m3 is None:
        return {"error": "missing field"}
    return {"amount": _charge(m3, type)}


def t_document(query=None, **_):
    if not query:
        return {"error": "missing field"}
    q = set(tokenize(query))
    best, code = -1, None
    for k, m in DOCUMENTS.items():
        o = len(q & set(tokenize(m)))
        if o > best:
            best, code = o, k
    return {"code": code, "text": DOCUMENTS[code]} if best > 0 else {"error": "not found"}


def t_record(subscriber=None, column=None, value=None, **_):
    if not (subscriber and column and value is not None):
        return {"error": "missing field"}
    return {"written": True, "subscriber": subscriber, "column": column, "value": value}


TOOLS = {
    "read_meter":    {"function": t_read_meter, "fields": ("subscriber",), "side_effect": False},
    "consumption":   {"function": t_consumption, "fields": ("previous", "current"),
                      "side_effect": False},
    "tariff":        {"function": t_tariff, "fields": ("m3", "type"), "side_effect": False},
    "search_docs":   {"function": t_document, "fields": ("query",), "side_effect": False},
    "write_record":  {"function": t_record, "fields": ("subscriber", "column", "value"),
                      "side_effect": True},
}
TOOL_NAMES = list(TOOLS)


def call_tool(name, state):
    """Fields outside the schema are filtered out; a missing field is the tool's own error."""
    CALLS[0] += 1
    if name not in TOOLS:
        return {"error": "no such tool"}
    kw = {a: state[a] for a in TOOLS[name]["fields"] if a in state}
    return TOOLS[name]["function"](**kw)


# ---- FICTIONAL tasks: each one is solved by a known tool sequence
TASKS = []
for i in range(24):
    r = rng(SEED + 9973 * i)
    sub = f"A{1 + int(r() * 60):03d}"
    t = pick(r(), [0.30, 0.22, 0.20, 0.28])
    if t == 0:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "charge", "subscriber": sub,
                      "request": f"{sub} abonesinin bu donem bedeli nedir",
                      "sequence": ("read_meter", "consumption", "tariff")})
    elif t == 1:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "consumption", "subscriber": sub,
                      "request": f"{sub} abonesinin bu donem tuketimi kac metrekup",
                      "sequence": ("read_meter", "consumption")})
    elif t == 2:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "document", "subscriber": sub,
                      "request": "sayac donusunde endeks farki nasil hesaplanir",
                      "sequence": ("search_docs",)})
    else:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "record", "subscriber": sub,
                      "request": f"{sub} abonesinin bedelini hesapla ve kayda yaz",
                      "sequence": ("read_meter", "consumption", "tariff", "write_record")})


def correct_answer(g):
    a = SUBSCRIBERS[g["subscriber"]]
    f = a["current"] - a["previous"]
    m3 = f + 100000 if f < 0 else f
    if g["kind"] == "consumption":
        return m3
    if g["kind"] == "document":
        return "SY01"
    return _charge(m3, a["type"])


# ---- FICTIONAL agent: at each step it picks a tool or finishes
def selection(d):
    return max(0.34, 0.80 - 0.09 * (d - 1))       # share of picking the right tool, falls with depth


def agent(g, max_steps=8, seed=SEED, self_critique=False, approval=False):
    """FICTIONAL agent loop. No real model is called; tool selection
    comes from a single draw that depends on the task's sequence and the depth."""
    r = rng(seed ^ trace_id(g["name"]))
    state = {"subscriber": g["subscriber"], "query": g["request"], "column": "charge"}
    trace, step, tok, errors = [], 0, 0, 0
    seq, k = g["sequence"], 0
    while step < max_steps:
        step += 1
        tok += 60 + 22 * len(trace)
        target = seq[k] if k < len(seq) else None
        if target is None:                         # the job is done , the agent should stop
            if r() < 0.88:
                return {"done": True, "steps": step, "trace": trace, "tokens": tok,
                        "errors": errors, "answer": state.get("result"),
                        "approvals": state.get("approvals", 0)}
            choice = TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        else:
            choice = target if r() < selection(k + 1) else TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        if TOOLS[choice]["side_effect"] and approval:
            state["approvals"] = state.get("approvals", 0) + 1
        if choice == "write_record":
            state["value"] = state.get("result")
        s = call_tool(choice, state)
        trace.append((choice, "error" in s))
        tok += 30
        if "error" in s:
            errors += 1
            if self_critique:
                tok += 40
            continue
        state.update({kk: vv for kk, vv in s.items() if kk != "error"})
        if choice == "consumption":
            state["result"] = s["m3"]
        if choice == "tariff":
            state["result"] = s["amount"]
        if choice == "search_docs":
            state["result"] = s["code"]
        if choice == target:
            k += 1
    return {"done": False, "steps": step, "trace": trace, "tokens": tok, "errors": errors,
            "answer": state.get("result"), "approvals": state.get("approvals", 0)}


print("shared environment:", len(SUBSCRIBERS), "subscribers ,", len(DOCUMENTS), "documents ,",
      len(TOOLS), "tools ,", len(TASKS), "tasks")
```

```
shared environment: 60 subscribers , 5 documents , 5 tools , 24 tasks
```

## Three Permission Sets and the Gate

The surface opens with three tools: `read_file`, `write_file`, `run_command`; `write_record` in the
shared set also declares a write capability. The gate compares this declaration with the permission set
and **only stops**; nowhere in the code is there a path that exceeds the limit, and every line that runs
is on the side of control. The twenty-four fictional surface tasks are spread over six kinds, and three
carry a side effect.

```python
# ---- FICTIONAL file tree and terminal surface (specific to this lesson)
TREE = {"data/index.csv": 60, "data/result.csv": 0, "docs/tariff.txt": 5,
        "config/threshold.cfg": 2, "logs/requests.log": 41}
COMMANDS = {"index_dump": 60, "log_tail": 12, "archive_cleanup": 7, "meter_reset": 0}
PATH_HINT = {"endek": "data/index.csv", "sonuc": "data/result.csv", "tarif": "docs/tariff.txt",
              "esik": "config/threshold.cfg", "gunlu": "logs/requests.log"}
COMMAND_HINT = {"dokum": "index_dump", "kuyru": "log_tail",
                 "temiz": "archive_cleanup", "sifir": "meter_reset"}


def resolve_path(a, mode):
    """Read takes the first match , write takes the last."""
    e = [PATH_HINT[t] for t in tokenize(a) if t in PATH_HINT]
    return (e[0] if mode == "read" else e[-1]) if e else "logs/requests.log"


def resolve_command(a):
    return next((COMMAND_HINT[t] for t in tokenize(a) if t in COMMAND_HINT), None)


def t_read_file(query="", subscriber=None, **_):
    path = resolve_path(query, "read")
    if path == "data/index.csv":
        a = SUBSCRIBERS.get(subscriber)
        return {"error": "missing field"} if a is None else {
            "previous": a["previous"], "current": a["current"], "type": a["type"]}
    if path == "docs/tariff.txt":
        return {"result": "TR01"}
    return {"result": ABOVE if path == "config/threshold.cfg" else TREE[path]}


def t_write_file(query="", result=None, **_):
    return {"error": "missing field"} if result is None else {"written": resolve_path(query, "write")}


def t_command(query="", **_):
    k = resolve_command(query)
    return {"error": "no such command"} if k is None else {"result": COMMANDS[k]}


TOOLS.update({"read_file": {"function": t_read_file, "fields": ("query", "subscriber"),
                            "side_effect": False},
              "write_file": {"function": t_write_file, "fields": ("query", "result"),
                             "side_effect": True},
              "run_command": {"function": t_command, "fields": ("query",),
                              "side_effect": True}})
TOOL_NAMES = list(TOOLS)
CAPABILITY = {"read_file": "read", "write_file": "write", "write_record": "write",
              "run_command": "execute"}
PERMISSION = {"full": {"read": ("",), "write": ("",), "command": None},
              "read-only": {"read": ("",), "write": (), "command": ()},
              "allowlisted": {"read": ("data/", "docs/", "config/"), "write": ("data/result",),
                              "command": ("index_dump", "log_tail")}}


def allow(name, state, p):
    """Compares the surface the tool declares with the permission set given."""
    g = CAPABILITY.get(name)
    if g is None:
        return True
    if g == "execute":
        return p["command"] is None or resolve_command(state.get("query", "")) in p["command"]
    if name == "write_record":
        return bool(p[g])
    return any(resolve_path(state.get("query", ""), g).startswith(o) for o in p[g])


ACTIVE, STOPPED = [PERMISSION["full"]], {"count": 0}
_execute = call_tool


def call_tool(name, state):
    """The gate stands in front of the executor; a call that does not pass never reaches the tool."""
    if allow(name, state, ACTIVE[0]):
        return _execute(name, state)
    STOPPED["count"] += 1
    return {"error": "no permission"}


# ---- FICTIONAL surface tasks: 24 tasks , six kinds
KINDS = [("index", "{a} abonesinin endeks satirini oku ve tuketimi hesapla",
          ("read_file", "consumption")),
         ("document", "tarife basamaklarini belgeden oku", ("read_file",)),
         ("threshold", "ustu birim ucret esik degerini ayardan oku", ("read_file",)),
         ("write", "{a} abonesinin endeks farkini hesapla ve sonuc dosyasina yaz",
          ("read_file", "consumption", "write_file")),
         ("dump", "endeks dokumunu calistir", ("run_command",)),
         ("cleanup", "arsiv temizligini calistir", ("run_command",))]
SURFACE = []
for i in range(24):
    r = rng(SEED + 6421 * i)
    sub = f"A{1 + int(r() * 60):03d}"
    kind, text, seq = KINDS[pick(r(), [0.20, 0.12, 0.14, 0.18, 0.18, 0.18])]
    SURFACE.append({"name": f"Y{i + 1:02d}", "kind": kind, "subscriber": sub,
                    "request": text.format(a=sub), "sequence": seq})


def surface_correct(g):
    a = SUBSCRIBERS[g["subscriber"]]
    return {"index": a["current"] - a["previous"], "write": a["current"] - a["previous"],
            "document": "TR01", "threshold": ABOVE, "dump": COMMANDS["index_dump"],
            "cleanup": COMMANDS["archive_cleanup"]}[g["kind"]]


def run(permission, seed=SEED):
    """For a task to be completed the answer must be correct and the step with a side effect must have run."""
    ACTIVE[0] = PERMISSION[permission]
    CALLS[0] = STOPPED["count"] = 0
    c = {"correct": [], "completed": [], "steps": 0, "share": {}}
    for g in SURFACE:
        before = STOPPED["count"]
        s = agent(g, seed=seed)
        d = s["answer"] == surface_correct(g)
        c["correct"].append(d)
        c["completed"].append(d and all(any(x == t and not h for x, h in s["trace"])
                                        for t in g["sequence"] if TOOLS[t]["side_effect"]))
        c["steps"] += s["steps"]
        c["share"][g["kind"]] = c["share"].get(g["kind"], 0) + STOPPED["count"] - before
    c.update(calls=CALLS[0], stopped=STOPPED["count"])
    return c


print("surface task kinds:", {t[0]: sum(g["kind"] == t[0] for g in SURFACE) for t in KINDS})
print("permission set correct A  correct B  completed  executed  stopped  steps  swapped")
table = {}
for p in PERMISSION:
    a, b = run(p), run(p, SEED + 1)
    table[p] = a
    print(f"{p:<14} {sum(a['correct']) / 24:>9.4f}  {sum(b['correct']) / 24:>9.4f}"
          f"  {sum(a['completed']) / 24:>9.4f}  {a['calls']:>8}  {a['stopped']:>7}"
          f"  {a['steps']:>5}  {sum(x != z for x, z in zip(a['completed'], b['completed'])):>7}")
print("\nstopped calls by task kind")
for p in ("read-only", "allowlisted"):
    print(f"  {p:<13}", {k: v for k, v in table[p]["share"].items() if v})
```

```
surface task kinds: {'index': 6, 'document': 3, 'threshold': 3, 'write': 5, 'dump': 4, 'cleanup': 3}
permission set correct A  correct B  completed  executed  stopped  steps  swapped
full              0.9583     0.9583     0.9583        54        0     78        2
read-only         0.6250     0.6250     0.5000        46       71    129        0
allowlisted       0.8333     0.8333     0.8333        51       20     92        2

stopped calls by task kind
  read-only     {'write': 22, 'cleanup': 19, 'dump': 30}
  allowlisted   {'cleanup': 20}
```

## Who Is Billed for Stopping

The baseline is the first row: the gate stops no call, and **0.9583** of the tasks finish. Lowering the
permission to read raises the stopped calls to **71** but drops the completed tasks to **0.5000**; the
allowlist stops **20** calls and stays at **0.8333**.

The real information is in the last two lines. The 71 stops of read-only are spread over three kinds:
`write` 22, `dump` 30, `cleanup` 19. All of the 20 stops of the allowlist, by contrast, are in the
`cleanup` kind. This is the measured form of the principle of least privilege: cutting permission by
capability drops three task kinds at once, while the allowlist loads the whole cost on the kind it
**wants to shut down**. Its difference from full permission is only **0.1250**, that is, three
`cleanup` tasks.

Whether this difference is measured is told by the last column. The totals of the two runs are the same
in all three rows, but a constant total does not mean run-to-run variability is zero: the **swapped**
column says that under full permission and under the allowlist **two** tasks each changed their result,
and that the total did not move because they canceled each other. The measurement band is
$2/24 = 0.0833$; 0.1250 is above the band and is **measured**, and the 0.4583 drop of read-only is far
above it.

Two readings remain. **The correct answer and the completed task separate under read-only**: 0.6250
correct, 0.5000 completed. In the three tasks in between, the agent produced the right number but **the
write call was stopped at the gate**; a measure that looks only at the answer would have counted all
three as finished. The steps column says stopping is not free: an error returned from the gate looks to
the loop like a tool error, and the agent retries the step. Read-only spends 129 steps instead of 78,
that is **65% more**, and finishes less work.

## Summary

- The permission surface is the capability a tool declares together with the path or command it
  touches; the gate compares this declaration with the permission set and only stops.
- Under full permission 0.9583 of the 24 tasks finish and no call stops; read-only stops 71 calls and
  lowers completed tasks to 0.5000; the allowlist stops 20 and stays at 0.8333.
- All 20 stops of the allowlist are in a single task kind, while those of read-only are spread over
  three kinds; this is the measurable difference of the principle of least privilege.
- A correct answer does not mean a completed task: under read-only the answer was correct in three tasks
  but the write was stopped, so the measure reads 0.5000 instead of 0.6250.
- Stopping is not free: read-only spends 129 steps, 65% more than the 78 steps of full permission; and
  because two tasks swapped places in the two runs, the measurement band is 0.0833.

## Next Step

In this lesson every surface the agent touched was **ours**: the file tree, the command set, and the
gate were all inside the measurement network. Being able to stop a call was possible because we knew who
was permitted to do what. When the agent brings in information from outside, this ground shifts: the
retrieved text is not ours, it cannot be checked by a permission set, and once it enters the state it is
carried to all the steps. The next lesson treats outside information as a **source classification** and
counts two things: how the class mix changes the share of correct answers, and how many steps a wrong
source is carried.
