@theokit/sdk 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +601 -0
- package/dist/{agent-YWSIHHFM.cjs → agent-47NS6ZVL.cjs} +13 -13
- package/dist/{agent-YWSIHHFM.cjs.map → agent-47NS6ZVL.cjs.map} +1 -1
- package/dist/{agent-DyB_lzrx.d.cts → agent-82d_DrCL.d.cts} +11 -2
- package/dist/{agent-CjMVbhhY.d.ts → agent-G6g-uwcB.d.ts} +11 -2
- package/dist/{agent-G3T7Q3SQ.js → agent-JJZM2VIK.js} +12 -12
- package/dist/{agent-G3T7Q3SQ.js.map → agent-JJZM2VIK.js.map} +1 -1
- package/dist/{chunk-ODBID5TG.js → chunk-2UXQASTX.js} +225 -30
- package/dist/chunk-2UXQASTX.js.map +1 -0
- package/dist/{chunk-VPK6PHIE.cjs → chunk-52FDUJSV.cjs} +8 -8
- package/dist/{chunk-VPK6PHIE.cjs.map → chunk-52FDUJSV.cjs.map} +1 -1
- package/dist/{chunk-2XRAWOZZ.js → chunk-5NELQ6LB.js} +65 -3
- package/dist/chunk-5NELQ6LB.js.map +1 -0
- package/dist/{chunk-VY6NKMOA.cjs → chunk-7IZKTQ5G.cjs} +34 -6
- package/dist/chunk-7IZKTQ5G.cjs.map +1 -0
- package/dist/{chunk-7SZAV6QG.js → chunk-7MMTZBTT.js} +3 -3
- package/dist/{chunk-7SZAV6QG.js.map → chunk-7MMTZBTT.js.map} +1 -1
- package/dist/{chunk-SADXXGWU.js → chunk-BZ3YMAMP.js} +3 -3
- package/dist/{chunk-NQTNSHSB.cjs.map → chunk-BZ3YMAMP.js.map} +1 -1
- package/dist/{chunk-LX7SEXOQ.js → chunk-CA5VUAP3.js} +25 -7
- package/dist/chunk-CA5VUAP3.js.map +1 -0
- package/dist/{chunk-IU5N5224.cjs → chunk-EIOMN5VA.cjs} +8 -8
- package/dist/chunk-EIOMN5VA.cjs.map +1 -0
- package/dist/{chunk-M3ZGRGOG.js → chunk-FPIY5CLV.js} +3 -3
- package/dist/{chunk-M3ZGRGOG.js.map → chunk-FPIY5CLV.js.map} +1 -1
- package/dist/{chunk-UKJJRU7C.cjs → chunk-GCHZMH42.cjs} +283 -86
- package/dist/chunk-GCHZMH42.cjs.map +1 -0
- package/dist/{chunk-NQTNSHSB.cjs → chunk-GFFBXSQT.cjs} +5 -5
- package/dist/chunk-GFFBXSQT.cjs.map +1 -0
- package/dist/{chunk-2ZLQZHZC.cjs → chunk-HG4UN4MN.cjs} +4 -4
- package/dist/{chunk-2ZLQZHZC.cjs.map → chunk-HG4UN4MN.cjs.map} +1 -1
- package/dist/{chunk-N6OOOYFZ.js → chunk-HUDNLFY4.js} +4 -4
- package/dist/chunk-HUDNLFY4.js.map +1 -0
- package/dist/{chunk-LOHMT36V.cjs → chunk-LD6HASA5.cjs} +65 -2
- package/dist/chunk-LD6HASA5.cjs.map +1 -0
- package/dist/{chunk-QATRS7JD.cjs → chunk-MYJGWS2J.cjs} +26 -8
- package/dist/chunk-MYJGWS2J.cjs.map +1 -0
- package/dist/{chunk-S6B5XYC3.js → chunk-N2KAIZ5D.js} +33 -6
- package/dist/chunk-N2KAIZ5D.js.map +1 -0
- package/dist/{chunk-HW7SEELD.cjs → chunk-QRVS2PRE.cjs} +31 -8
- package/dist/chunk-QRVS2PRE.cjs.map +1 -0
- package/dist/{chunk-AYA65JA5.cjs → chunk-RWPLWMCZ.cjs} +25 -9
- package/dist/chunk-RWPLWMCZ.cjs.map +1 -0
- package/dist/{chunk-WS5ULCL4.js → chunk-STGSMJMJ.js} +3 -3
- package/dist/{chunk-WS5ULCL4.js.map → chunk-STGSMJMJ.js.map} +1 -1
- package/dist/{chunk-O7L7M42F.js → chunk-T3ZDEYTJ.js} +21 -5
- package/dist/chunk-T3ZDEYTJ.js.map +1 -0
- package/dist/{chunk-43YXGD3P.cjs → chunk-TY56BKSK.cjs} +8 -4
- package/dist/chunk-TY56BKSK.cjs.map +1 -0
- package/dist/chunk-UOLBAPDM.js +66 -0
- package/dist/chunk-UOLBAPDM.js.map +1 -0
- package/dist/{chunk-Z2JFX372.cjs → chunk-VUHXC74Q.cjs} +15 -15
- package/dist/{chunk-Z2JFX372.cjs.map → chunk-VUHXC74Q.cjs.map} +1 -1
- package/dist/{chunk-NSLHPAC7.js → chunk-X7EUUHXU.js} +6 -5
- package/dist/chunk-X7EUUHXU.js.map +1 -0
- package/dist/context/index.cjs +7 -7
- package/dist/context/index.js +3 -3
- package/dist/{context-JTGSBJT6.cjs → context-HR4KMXMA.cjs} +7 -7
- package/dist/{context-JTGSBJT6.cjs.map → context-HR4KMXMA.cjs.map} +1 -1
- package/dist/context-J5BJ3LBS.js +6 -0
- package/dist/{context-3HPU754C.js.map → context-J5BJ3LBS.js.map} +1 -1
- package/dist/{cron-Bgivg88c.d.cts → cron-DWv69ZSD.d.cts} +1 -1
- package/dist/{cron-CNUa7PDo.d.ts → cron-GynWtAax.d.ts} +1 -1
- package/dist/cron.cjs +12 -12
- package/dist/cron.d.cts +2 -2
- package/dist/cron.d.ts +2 -2
- package/dist/cron.js +11 -11
- package/dist/eval.cjs +11 -11
- package/dist/eval.js +10 -10
- package/dist/{index-manager-W7FDMGEG.js → index-manager-27WLNQEE.js} +5 -5
- package/dist/{index-manager-W7FDMGEG.js.map → index-manager-27WLNQEE.js.map} +1 -1
- package/dist/{index-manager-3UNPYH34.cjs → index-manager-BBHDKMQS.cjs} +6 -6
- package/dist/{index-manager-3UNPYH34.cjs.map → index-manager-BBHDKMQS.cjs.map} +1 -1
- package/dist/index.cjs +274 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +168 -5
- package/dist/index.d.ts +168 -5
- package/dist/index.js +246 -23
- package/dist/index.js.map +1 -1
- package/dist/internal/memory/storage/index.cjs +32 -32
- package/dist/internal/memory/storage/index.js +3 -3
- package/dist/internal/memory/storage/memory-root.d.cts +27 -0
- package/dist/internal/memory/storage/memory-root.d.ts +27 -0
- package/dist/internal/persistence/index.cjs +4 -4
- package/dist/internal/persistence/index.js +1 -1
- package/dist/internal/runtime/compat/foreign-config-sources.d.ts +17 -4
- package/dist/internal/runtime/compat/managed-settings.d.ts +80 -0
- package/dist/internal/runtime/context/context-discovery-runner.d.ts +14 -0
- package/dist/internal/runtime/context/context-discovery.d.ts +37 -0
- package/dist/internal/runtime/context/context-manager.d.ts +21 -1
- package/dist/internal/runtime/context/yaml-frontmatter.d.ts +6 -3
- package/dist/internal/runtime/hooks/hooks-executor.d.ts +13 -1
- package/dist/internal/runtime/hooks/hooks-source.d.ts +36 -1
- package/dist/internal/runtime/skills/discover-skills.d.ts +4 -0
- package/dist/project.cjs +3 -3
- package/dist/project.js +1 -1
- package/dist/skills.cjs +5 -5
- package/dist/skills.js +2 -2
- package/dist/subagents-loader-CJFYQQU2.js +7 -0
- package/dist/{subagents-loader-AIVDQ2D5.js.map → subagents-loader-CJFYQQU2.js.map} +1 -1
- package/dist/subagents-loader-MOO7DC4E.cjs +16 -0
- package/dist/{subagents-loader-DN4LETGL.cjs.map → subagents-loader-MOO7DC4E.cjs.map} +1 -1
- package/dist/subagents-loader.cjs +4 -4
- package/dist/subagents-loader.d.cts +1 -1
- package/dist/subagents-loader.d.ts +1 -1
- package/dist/subagents-loader.js +3 -3
- package/dist/types/agent.d.ts +6 -1
- package/docs/error-codes.md +20 -18
- package/docs/harness-capability-map.md +9 -1
- package/package.json +1 -1
- package/dist/chunk-2XRAWOZZ.js.map +0 -1
- package/dist/chunk-43YXGD3P.cjs.map +0 -1
- package/dist/chunk-AYA65JA5.cjs.map +0 -1
- package/dist/chunk-HW7SEELD.cjs.map +0 -1
- package/dist/chunk-IU5N5224.cjs.map +0 -1
- package/dist/chunk-JNAA4G4H.js +0 -43
- package/dist/chunk-JNAA4G4H.js.map +0 -1
- package/dist/chunk-LOHMT36V.cjs.map +0 -1
- package/dist/chunk-LX7SEXOQ.js.map +0 -1
- package/dist/chunk-N6OOOYFZ.js.map +0 -1
- package/dist/chunk-NSLHPAC7.js.map +0 -1
- package/dist/chunk-O7L7M42F.js.map +0 -1
- package/dist/chunk-ODBID5TG.js.map +0 -1
- package/dist/chunk-QATRS7JD.cjs.map +0 -1
- package/dist/chunk-S6B5XYC3.js.map +0 -1
- package/dist/chunk-SADXXGWU.js.map +0 -1
- package/dist/chunk-UKJJRU7C.cjs.map +0 -1
- package/dist/chunk-VY6NKMOA.cjs.map +0 -1
- package/dist/context-3HPU754C.js +0 -6
- package/dist/subagents-loader-AIVDQ2D5.js +0 -7
- package/dist/subagents-loader-DN4LETGL.cjs +0 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,606 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - Two tiers no permission rule and no mode can reach: protected paths and critical paths.
|
|
8
|
+
|
|
9
|
+
Measured: `protectedPath` returned 0 files here and 0 in the dist; `criticalPath` the same, against a
|
|
10
|
+
control of 31/72 on the word `hooks`.
|
|
11
|
+
|
|
12
|
+
**Protected paths** are the circuit breaker that stops an agent editing its own configuration, the
|
|
13
|
+
git hooks or the shell rc files — `.git`, `.claude`, `.theokit`, `.ssh`, `.gnupg`, `.envrc`,
|
|
14
|
+
`.npmrc`, `.mcp.json`, `.pre-commit-config.yaml`, `.netrc`. An `allow`-broad setup wrote all of them
|
|
15
|
+
freely and the operator had no way to express the exception, because the concept was absent.
|
|
16
|
+
|
|
17
|
+
**Critical paths** are destructive operations on `/`, the home directory, the working directory and
|
|
18
|
+
its parents. The nearest analogue was `catastrophicShellReason` in `@theokit/sdk-tools`, reached
|
|
19
|
+
through an opt-in `denyCatastrophicCommands()` — and **an opt-in guard is not a floor**. The whole
|
|
20
|
+
point of this tier is that nothing overrides it; a function a consumer may forget to call makes the
|
|
21
|
+
guarantee a convention.
|
|
22
|
+
|
|
23
|
+
**Why a floor and not a deny rule.** A deny rule is ordered, and order is defeasible: it can be
|
|
24
|
+
shadowed by a broader rule above it, reordered, or simply not shipped. The floor is consulted before
|
|
25
|
+
any verdict is honoured and no rule can reach it. Each test pairs the refusal with an explicit allow
|
|
26
|
+
rule for the same call, because the point is not that the operation is refused — it is that the rule
|
|
27
|
+
loses.
|
|
28
|
+
|
|
29
|
+
**Substitutions are refused, not expanded.** The spec names `$(...)` and `"$VAR"/*`, and in both the
|
|
30
|
+
dangerous argument is not in the text being matched. Expanding would mean running the substitution,
|
|
31
|
+
and a floor that executes its input to decide whether the input is safe has the problem backwards.
|
|
32
|
+
|
|
33
|
+
**Segment-aware, never prefix-matching.** `/workspace-other` starts with `/work` as a string and is a
|
|
34
|
+
different directory; `.gitignore` contains `.git` and is a file projects edit routinely. A floor that
|
|
35
|
+
could not tell them apart would refuse ordinary work while claiming to protect something else.
|
|
36
|
+
|
|
37
|
+
The refusal **names the path and says which tier refused**, so it is not mistaken for a permissions
|
|
38
|
+
misconfiguration — an operator who reads "denied" goes looking at their rules and finds nothing wrong
|
|
39
|
+
with them.
|
|
40
|
+
|
|
41
|
+
**Reads are not gated**, deliberately: the tier is about writes and destruction, and refusing reads
|
|
42
|
+
would stop an agent inspecting the repository it was pointed at.
|
|
43
|
+
|
|
44
|
+
Stated rather than implied: this is **not a shell parser**. It recognises the destructive shapes the
|
|
45
|
+
spec names, on the arguments it names, and a determined obfuscation gets past it — `rm` reached
|
|
46
|
+
through a variable holding the command name, for instance. A floor described as complete would be
|
|
47
|
+
trusted as complete.
|
|
48
|
+
|
|
49
|
+
- [#660](https://github.com/usetheokit/theokit-sdk/pull/660) [`612da2f`](https://github.com/usetheokit/theokit-sdk/commit/612da2fcb96ce498973aeb60e05650e32f7c4216) Thanks [@usetheodev](https://github.com/usetheodev)! - A foreign root's rules now need the grant that already gates everything else in that directory.
|
|
50
|
+
|
|
51
|
+
`FileContextManager.initialize()` gated project-level context on
|
|
52
|
+
`settingSourcesIncludeProject || settings.manager === "file"` and consulted no foreign-dialect
|
|
53
|
+
grant. `compatSources` appeared nowhere under `src/internal/runtime/context/`. So a consumer who
|
|
54
|
+
enabled project scope for its OWN `.theokit/` and deliberately never declared `claude-code` still
|
|
55
|
+
received a cloned repository's `.claude/rules/*.md` in its system prompt — while the same
|
|
56
|
+
directory's hooks, skills, subagents and plugins were correctly withheld. Four surfaces failing
|
|
57
|
+
closed, and a fifth nobody had wired to the gate ([#652](https://github.com/usetheokit/theokit-sdk/issues/652)).
|
|
58
|
+
|
|
59
|
+
The root cause was not a missing `if`. `CompatSurface` was `"hooks" | "plugins" | "skills" |
|
|
60
|
+
"subagents"`: there was no member for instructions, so no grant could govern them and the gate had
|
|
61
|
+
nothing to consult. `"context"` is now a surface like the others, paired to its runtime list by the
|
|
62
|
+
existing compile-time exhaustiveness guard, and each discovery spec names the dialect whose grant
|
|
63
|
+
gates it. Adding a dialect is adding a row.
|
|
64
|
+
|
|
65
|
+
**This is a behaviour change, and the note is here rather than buried.** A consumer who declared no
|
|
66
|
+
compat source stops receiving `.claude/rules/*.md`. It is released as a minor because it aligns one
|
|
67
|
+
surface with the four that already fail closed, because the loss is announced at runtime by the
|
|
68
|
+
undeclared-source warning (which now names rules alongside the others), and because there is an
|
|
69
|
+
explicit way back: `compatSources: ["claude-code"]`, or `{ kind: "claude-code", import: ["context"] }`
|
|
70
|
+
for that surface alone. A reader who weighs the removal differently should say so before the cut.
|
|
71
|
+
|
|
72
|
+
**Release ordering, measured rather than assumed.** This gate must ship AFTER its consumers have a
|
|
73
|
+
name to grant. `@theokit/agents@13.4.0` — the version `TheoCode` resolves today — declares
|
|
74
|
+
`CompatSurface = 'commands' | 'hooks' | 'plugins' | 'skills' | 'subagents'`, with no `context`, and
|
|
75
|
+
`TheoCode` passes exactly that list as its narrowed `import`. Cutting this release first would take
|
|
76
|
+
`.claude/rules` from it silently, with no word it could write to ask for them back. The order is:
|
|
77
|
+
`@theokit/agents` publishes the vocabulary, consumers declare `context`, then this.
|
|
78
|
+
|
|
79
|
+
**What is deliberately NOT gated**, because an undocumented gap reads as an oversight:
|
|
80
|
+
`AGENTS.md`, `GEMINI.md` and `.cursor/rules/*.mdc` are every bit as foreign, and `adaptersFor`
|
|
81
|
+
registers no adapter for any of them — so `compatSources` has no spelling that admits one, and
|
|
82
|
+
gating them would strand three formats with no way to restore them. `CLAUDE.md` is left ungated by
|
|
83
|
+
judgement rather than by limit: the grant gates the foreign ROOT, that file sits at the repository
|
|
84
|
+
root beside the other three, and projects with no `.claude/` at all use it as a generic
|
|
85
|
+
agent-instructions file. Whether a repo-root instruction file should require an opt-in is a product
|
|
86
|
+
decision affecting every consumer, not a bug fix.
|
|
87
|
+
|
|
88
|
+
- [#661](https://github.com/usetheokit/theokit-sdk/pull/661) [`f94d92c`](https://github.com/usetheokit/theokit-sdk/commit/f94d92c396c20c6f7ebeb7572894eb8c9cbb8b0a) Thanks [@usetheodev](https://github.com/usetheodev)! - `AGENTS.local.md`, `CLAUDE.local.md` and `THEO.local.md` are discovered, and composed last.
|
|
89
|
+
|
|
90
|
+
The gitignored companion is where an operator keeps the standing corrections too personal or too
|
|
91
|
+
situational to commit. Nothing discovered it. Measured 2026-09-12: a grep for the four `.local`
|
|
92
|
+
spellings returned **0 files** across this package's source, against a control of 23 for
|
|
93
|
+
`CLAUDE.md`. The failure is the silent kind — the file exists, it is named the documented way,
|
|
94
|
+
nothing loads it, and nothing complains, so the agent behaves exactly as it would if the operator
|
|
95
|
+
had written nothing.
|
|
96
|
+
|
|
97
|
+
**Order, and the cost of it, stated rather than discovered later.** The three specs sit above every
|
|
98
|
+
public one (priorities 70/75/80) because a correction has to be composed after the rule it corrects,
|
|
99
|
+
and they keep the public chain's relative order among themselves so both halves read the same way.
|
|
100
|
+
`applyAggregateCap` fills the budget in ascending priority, so the highest numbers are the first
|
|
101
|
+
dropped when the total cap is reached — placing the private chain last therefore makes it the first
|
|
102
|
+
to go under pressure. The alternative, a low number to protect it, would compose the operator's
|
|
103
|
+
refinement *before* the general rule and invert its meaning, which is the defect this closes. The
|
|
104
|
+
table already accepts that trade: `.theokit/THEO.md`, the most specific public file, sits at 60 and
|
|
105
|
+
is equally droppable.
|
|
106
|
+
|
|
107
|
+
**Three and not six.** A private companion pairs with a public file this seam reads, and the
|
|
108
|
+
documented convention is THEO / AGENTS / CLAUDE. `GEMINI.local.md` and a private `.cursor/rules` are
|
|
109
|
+
not part of it, and inventing them would publish a convention nobody writes.
|
|
110
|
+
|
|
111
|
+
**Ungated**, like the repo-root files beside them. `CLAUDE.local.md` sits at the repository root
|
|
112
|
+
rather than inside `.claude/`, so it follows `CLAUDE.md` and not `claude-rules` — the grant added in
|
|
113
|
+
[#652](https://github.com/usetheokit/theokit-sdk/issues/652) gates the foreign *root*, not the files beside it.
|
|
114
|
+
|
|
115
|
+
The chains stay independent: a private file never replaces its public sibling. One falling back to
|
|
116
|
+
the other is the trap, where adding a `THEO.md` would silently orphan an existing `AGENTS.local.md`.
|
|
117
|
+
|
|
118
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - An organisation can impose policy on an agent. `managed-settings.json` is read, and the project cannot switch it off.
|
|
119
|
+
|
|
120
|
+
Measured: `grep -rl managed-settings` returned 0 here, 0 in the 4.52.1 dist and 0 in the 5.5.0 dist,
|
|
121
|
+
against a control of 31 files for `hooks`. Claude Code defines the file as settings a user "cannot
|
|
122
|
+
override, except for limited exceptions". An organisation that deployed one got it dropped in
|
|
123
|
+
silence, while the same file was enforced by the tool it was written for.
|
|
124
|
+
|
|
125
|
+
**This was not an incomplete feature — it was an ignored security control**, and the failure
|
|
126
|
+
direction is permit.
|
|
127
|
+
|
|
128
|
+
The decision behind it is recorded in `packages/agents/README.md` § "Who decides policy": an
|
|
129
|
+
operator who did not write the code CAN impose policy on it. Hooks, MCP servers, permissions and
|
|
130
|
+
skill execution were each a value the *programmer* passed at build time — defensible for a framework,
|
|
131
|
+
indefensible for anything an organisation deploys, because the person answerable for what an agent
|
|
132
|
+
may do on a machine had no way to say so.
|
|
133
|
+
|
|
134
|
+
Precedence, highest first: `managed-settings.json` → the project's `settings.json` →
|
|
135
|
+
`defineAgent({ … })`.
|
|
136
|
+
|
|
137
|
+
**The first control lifted is `disableAllHooks`**, because its absence is the hardest to notice: a
|
|
138
|
+
hook that does not run looks identical to a hook that ran and approved. It is a **veto, not a
|
|
139
|
+
merge** — a project file setting `disableAllHooks: false` loses, since a tier the layer below can
|
|
140
|
+
switch off is not a tier. It is also checked before `settingSourcesIncludeProject`, which is the
|
|
141
|
+
programmer choosing whether to read the project's files at all.
|
|
142
|
+
|
|
143
|
+
**Unknown keys are reported, never carried.** An organisation writing `forceModel` into the policy
|
|
144
|
+
and getting silence would conclude the model is forced; carrying the key through would spread that
|
|
145
|
+
belief downstream. The reports go through `diagFailure`, not `diag` — `diag` returns immediately when
|
|
146
|
+
no sink is installed, and most consumers never install one, so a policy channel using it would be
|
|
147
|
+
silent by default about the one thing this tier exists to make certain.
|
|
148
|
+
|
|
149
|
+
`readManagedSettings` and `ManagedSettings` cross the barrel so a HOST can read the policy the
|
|
150
|
+
runtime enforces instead of guessing at it. This is not the only reader: `@theokit/agents` ships from
|
|
151
|
+
a separate repository against a *published* version of this package, so it carries its own reader of
|
|
152
|
+
the same file. One FORMAT is the contract; two readers that release independently is a consequence of
|
|
153
|
+
the repository boundary.
|
|
154
|
+
|
|
155
|
+
Platform paths are Claude Code's own (`/etc/claude-code/`, `/Library/Application Support/ClaudeCode/`,
|
|
156
|
+
`%PROGRAMDATA%\ClaudeCode\`), so an organisation that already deployed a policy does not have to
|
|
157
|
+
deploy a second copy under a different name.
|
|
158
|
+
|
|
159
|
+
`permissionMode` and `permissions` join `disableAllHooks` and `disableSkillShellExecution` as keys an
|
|
160
|
+
operator may impose. `plan` is the one the posture key exists for — an explore-only run where edits
|
|
161
|
+
are structurally refused; a plan-mode *tool* already existed and it is something the model may call,
|
|
162
|
+
and the difference is who decides. A posture outside the four is reported and ignored: `"readonly"`
|
|
163
|
+
is what somebody writes when they mean `plan`, and applying it by shape would enforce a posture
|
|
164
|
+
nobody defined while dropping it silently would leave them believing edits are refused.
|
|
165
|
+
|
|
166
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - A permission policy can be data an operator ships, reviews and diffs. It could only be a compiled-in function.
|
|
167
|
+
|
|
168
|
+
Measured: `allowedTools` and `disallowedTools` returned 0 files in the agents layer, and the closest
|
|
169
|
+
facility was `CommandPolicy = (command: string) => string | null` — a code predicate. The spec's rule
|
|
170
|
+
language had no counterpart: `Bash(` 0/0, `domain:` 1/0 and that one in prose.
|
|
171
|
+
|
|
172
|
+
The consequence is not a missing convenience. Every policy was a function, so it could not be
|
|
173
|
+
audited, diffed, reviewed in a pull request, or varied per environment — and `Read(./.env)`, the
|
|
174
|
+
spec's own paste-ready secret-exclusion example, could not be expressed at all.
|
|
175
|
+
|
|
176
|
+
**The grammar is decided as a whole**, one shape with a self-describing specifier:
|
|
177
|
+
|
|
178
|
+
| Written | Matches |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `Bash` | every call to `Bash` |
|
|
181
|
+
| `Bash(npm run test:*)` | the argument starts with `npm run test:` |
|
|
182
|
+
| `Bash(npm audit)` | the argument equals `npm audit` — not a prefix |
|
|
183
|
+
| `Read(path:./.env)` | the argument, read as a path, matches the glob |
|
|
184
|
+
| `WebFetch(domain:example.com)` | the argument's HOST equals `example.com` |
|
|
185
|
+
|
|
186
|
+
The specifier says how to read itself. Per-tool magic — knowing that `Read` means a path — cannot
|
|
187
|
+
work where the consumer brings their own tools: a rule naming a tool this SDK has never heard of must
|
|
188
|
+
still be readable.
|
|
189
|
+
|
|
190
|
+
**A domain matches the HOST, never a substring.** `example.com.evil.test` is a different host, and a
|
|
191
|
+
substring match here would be an open redirect in policy form.
|
|
192
|
+
|
|
193
|
+
**A path glob is anchored at both ends.** Unanchored fails in both directions — a file outside the
|
|
194
|
+
protected tree matches because the pattern appears in its path, and a file inside escapes by having
|
|
195
|
+
anything appended. It is built from the literal with every other metacharacter escaped, so a policy
|
|
196
|
+
line cannot smuggle a regular expression into the matcher.
|
|
197
|
+
|
|
198
|
+
**Deny is emitted first**, then `ask`, then `allow`. The engine is first-match, so emission order *is*
|
|
199
|
+
precedence — and a narrow deny must survive a broad allow, or `Read` plus `Read(path:./.env)` would
|
|
200
|
+
read the secret the second line exists to protect.
|
|
201
|
+
|
|
202
|
+
**The engine is untouched.** A specifier becomes one rule per conventional argument name
|
|
203
|
+
(`command`, `file_path`, `path`, `url`, `query`) rather than a matcher that inspects the whole call:
|
|
204
|
+
`ArgMatcher` receives a single value, and `#argsMatch` fails a matcher whose argument is absent — an
|
|
205
|
+
invariant with its own history ([#367](https://github.com/usetheokit/theokit-sdk/issues/367), where a predicate invoked with `undefined` widened an allow
|
|
206
|
+
rule written to narrow). Widening that to add a grammar would trade a tested invariant for a parser.
|
|
207
|
+
|
|
208
|
+
**The limit is stated, not guessed around.** A tool whose argument is named something else cannot be
|
|
209
|
+
narrowed by specifier. "Read whichever argument is the only string" was the alternative and is worse:
|
|
210
|
+
a rule would match an argument the operator never named, widening an allow rule exactly as often as
|
|
211
|
+
it narrows a deny one. A bare tool name always works and matches every call.
|
|
212
|
+
|
|
213
|
+
A rule this grammar cannot read is **refused**, not dropped. A policy line an operator wrote and the
|
|
214
|
+
runtime silently ignored is the belief-in-an-absent-protection this tier exists to remove.
|
|
215
|
+
|
|
216
|
+
**B-038 is decided by this change rather than inherited by it.** The engine is first-match over an
|
|
217
|
+
array while the format groups by category, so a ported file listing `allow` above `deny` for the same
|
|
218
|
+
tool would silently invert and the narrower deny would never be reached. The decision is that the
|
|
219
|
+
LOADER reorders, not that the engine gains category evaluation: the array semantics are what every
|
|
220
|
+
existing consumer already built rules against, and changing how it walks them would move ground under
|
|
221
|
+
code nobody asked to change. A fixture writing `allow` first pins it.
|
|
222
|
+
|
|
223
|
+
**A rule written in the documented MCP spelling now matches.** The reference names an MCP tool
|
|
224
|
+
`mcp__server__tool` with a double underscore; this runtime names the same tool
|
|
225
|
+
`mcp_server_tool`, single, because the name is sanitised for the provider. Every permission rule an
|
|
226
|
+
operator copied from the documentation missed its target **silently** — the deny read as configured
|
|
227
|
+
and the tool ran.
|
|
228
|
+
|
|
229
|
+
Normalised in the RULE, never in the runtime name: the runtime spelling is what the model sees and
|
|
230
|
+
what the provider validates, and changing it would break every rule already written against it and
|
|
231
|
+
every consumer matching it, to fix a mismatch that costs one substitution at parse time. Scoped to
|
|
232
|
+
the `mcp__` prefix rather than rewriting every double underscore, because a tool outside MCP is
|
|
233
|
+
entitled to one in its own name.
|
|
234
|
+
|
|
235
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`ddb9306`](https://github.com/usetheokit/theokit-sdk/commit/ddb930614c55e1aab2f970e148c8c7be46c8f104) Thanks [@usetheodev](https://github.com/usetheodev)! - The protected-path and critical-path floors now fire from the runtime, before the permission engine
|
|
236
|
+
and before the consumer's `canUseTool` gate.
|
|
237
|
+
|
|
238
|
+
They did not. `permission-floors.ts` was imported by `src/index.ts` and by nothing else, so a tier
|
|
239
|
+
whose own documentation says it sits "above every permission rule and every mode" was consulted only
|
|
240
|
+
if a consumer remembered to call `permissionFloorReason` themselves. A guarantee that depends on
|
|
241
|
+
being remembered is as strong as the memory, which is the failure this slice was written to remove.
|
|
242
|
+
|
|
243
|
+
`permission-plugin.ts` — the `pre_tool_call` decision point — now asks the floor FIRST. An `allow`
|
|
244
|
+
rule, an allowing gate and `permissionMode: "bypassPermissions"` together no longer reach a write
|
|
245
|
+
into `.claude/` or `.theokit/`, nor a destructive operation on a critical root. `bypassPermissions`
|
|
246
|
+
not reaching the floor is deliberate: a mode that skips it is a mode that can rewrite the policy
|
|
247
|
+
meant to bound it.
|
|
248
|
+
|
|
249
|
+
The `@theokit/sdk` bundle budget moves 27000 → 28000 gzipped as a direct consequence: the runtime
|
|
250
|
+
now carries a security tier it did not carry before (27338, 98% of the new ceiling). The headroom is
|
|
251
|
+
662 bytes, so unintended growth still trips the gate.
|
|
252
|
+
|
|
253
|
+
### Patch Changes
|
|
254
|
+
|
|
255
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - Block-style YAML lists in frontmatter now parse instead of vanishing.
|
|
256
|
+
|
|
257
|
+
`parseSimpleYaml` is line-oriented: it splits each line on the first `:` and coerces what follows.
|
|
258
|
+
A continuation line `- item` has no colon, so it was skipped outright, while the key line above it
|
|
259
|
+
had an empty value and coerced to `undefined`. The key disappeared entirely — a reader saw
|
|
260
|
+
`paths:` on disk, found no behaviour, and had nothing to grep for.
|
|
261
|
+
|
|
262
|
+
Two things made this worth fixing rather than documenting:
|
|
263
|
+
|
|
264
|
+
- **The file's own docblock recommended the shape it could not read.** Listing the inline form's
|
|
265
|
+
comma limitation, it advised "Use multi-line lists or reword if you need this." Multi-line lists
|
|
266
|
+
were the one shape this parser did not support.
|
|
267
|
+
- **Two parsers in this package disagreed about the same frontmatter.** The sibling
|
|
268
|
+
`context-yaml-lite.ts` already reads block lists, and its comment records that adding them was a
|
|
269
|
+
repair rather than a feature. Which loader read a file decided whether its list existed.
|
|
270
|
+
|
|
271
|
+
Blank and `#` lines do not end a list; the first other line does, and the caller resumes there — so
|
|
272
|
+
the key written after a block list is no longer swallowed by it. A bare `key:` with no items under
|
|
273
|
+
it still yields `undefined`, which is what a caller's Zod default relies on.
|
|
274
|
+
|
|
275
|
+
The collection and the key/value split moved into `collectBlockList` and `splitEntry`. That is not
|
|
276
|
+
tidying: inlining the collection put `parseSimpleYaml` at a cognitive complexity of 24 against the
|
|
277
|
+
repository's limit of 10, measured on the same file path where the pre-change version raised no
|
|
278
|
+
such diagnostic.
|
|
279
|
+
|
|
280
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - A subagent frontmatter field that belongs to Claude Code now says so, and names its siblings.
|
|
281
|
+
|
|
282
|
+
**The failure itself is unchanged, deliberately.** A file that declares frontmatter and gets it
|
|
283
|
+
wrong is a broken agent and still fails the load — the reason is recorded a few lines above, where
|
|
284
|
+
skipping was added only for files with _no_ frontmatter: "a file that HAS frontmatter and gets it
|
|
285
|
+
wrong is a broken agent and still fails loudly, which is what keeps a typo'd `sandbox` from
|
|
286
|
+
returning as a silent gate through this door." Isolating the failure per file would hand that risk
|
|
287
|
+
back.
|
|
288
|
+
|
|
289
|
+
What changes is the diagnosis. A user migrating a `.claude/agents/` tree learned one key per round
|
|
290
|
+
trip: fix `memory`, meet `permissionMode`, fix that, meet `maxTurns` — with nothing saying the set
|
|
291
|
+
was finite or that the tree was simply written for another runtime. The error now distinguishes
|
|
292
|
+
"another runtime's field" from "never heard of this", and lists the other eleven that will behave
|
|
293
|
+
the same way.
|
|
294
|
+
|
|
295
|
+
That distinction already exists here for `INERT_CLAUDE_CODE_FIELDS`, described as "the difference
|
|
296
|
+
between 'we know this one and it does nothing' and 'we have never heard of this' — two facts a bare
|
|
297
|
+
allow-everything would collapse into one." This adds a third fact beside them and changes only the
|
|
298
|
+
message, never the verdict.
|
|
299
|
+
|
|
300
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook field this runtime does not implement is now refused instead of dropped in silence.
|
|
301
|
+
|
|
302
|
+
`parseClaudeCodeCommand` read `type`, `command` and `timeout` and discarded everything else on the
|
|
303
|
+
entry — `if`, `args`, `statusMessage`, `once`, `async`, `asyncRewake`, `shell` — with no error and
|
|
304
|
+
no warning.
|
|
305
|
+
|
|
306
|
+
**`if` is the field that makes this a defect rather than a missing feature.** For the others the
|
|
307
|
+
loss is a convenience. For `if` it is the opposite of what the operator wrote: a deny hook narrowed
|
|
308
|
+
to one dangerous command shape silently becomes a deny hook over *every* call of that tool. The
|
|
309
|
+
guard still runs, so nothing looks broken; it simply applies where it was told not to.
|
|
310
|
+
|
|
311
|
+
The fix is refusal, not implementation. Implementing `if` means adopting a condition language whose
|
|
312
|
+
semantics nobody here has decided; refusing the field costs one throw and cannot be wrong about what
|
|
313
|
+
the operator meant. The direction is what matters — a dropped `if` fails open, a refused `if` fails
|
|
314
|
+
closed and names the field that stopped the load.
|
|
315
|
+
|
|
316
|
+
`packages/agents`, reading the same file one layer up, already took this side: its `hookSpecSchema`
|
|
317
|
+
is `.strict()` and refuses an unknown key loudly. Two layers disagreed about whether a field was an
|
|
318
|
+
error, and the permissive one was the layer that actually ran the hook.
|
|
319
|
+
|
|
320
|
+
The error separates "a Claude Code hook field this runtime does not implement" from "never heard of
|
|
321
|
+
this", and lists the siblings that will behave the same way — so an operator migrating a `.claude/`
|
|
322
|
+
tree does not learn one key per round trip. A non-command `type` still fails for its own reason: the
|
|
323
|
+
new check runs after the existing ones.
|
|
324
|
+
|
|
325
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - `skill_read` can read a skill discovered on disk. It could not, which made the entire on-disk skills surface decorative.
|
|
326
|
+
|
|
327
|
+
`SkillReadTool.create` took `ReadonlyArray<InlineSkill>` — the shape that carries `instructions` on
|
|
328
|
+
the object. A disk-discovered skill is a `Skill`, whose own declaration says "the skill BODY is never
|
|
329
|
+
included", so it did not fit through the door at all. The result: a `SKILL.md` was listed by name and
|
|
330
|
+
description in the `<skills>` block, the model asked to read it, and got back a heading and an empty
|
|
331
|
+
body — measured, the handler returned `"# Skill: deploy\n\n"`.
|
|
332
|
+
|
|
333
|
+
The instructions under the frontmatter are the skill. Every other skill defect in this backlog is a
|
|
334
|
+
field inside a file whose body never showed up.
|
|
335
|
+
|
|
336
|
+
`create` now accepts `ReadableSkill` — a skill that carries its body, or one that knows where its
|
|
337
|
+
body is — and the handler resolves the difference. Passing inline skills is unchanged.
|
|
338
|
+
|
|
339
|
+
**Read when the model asks, not at construction.** Eager reading would turn "this agent knows about
|
|
340
|
+
twelve skills" into twelve file reads at startup, to answer a question the model usually does not
|
|
341
|
+
ask. The handler's contract already allowed `Promise<string>`, so laziness cost one `await` and no
|
|
342
|
+
new API.
|
|
343
|
+
|
|
344
|
+
The handler is deliberately NOT `async`. Marking the whole function async turns the input schema's
|
|
345
|
+
synchronous throw into a rejected promise, and this module's contract is that malformed input "fails
|
|
346
|
+
at the trust boundary via the schema". Measured while making this change: two trust-boundary tests
|
|
347
|
+
went from throwing to returning `undefined`. Only the body read is asynchronous — parse and the
|
|
348
|
+
not-found answer stay exactly as synchronous as they were.
|
|
349
|
+
|
|
350
|
+
A disk skill's `references/` directory is now read too, for the same reason inline skills already
|
|
351
|
+
render theirs in full: the two shapes describe the same thing, and one of them arriving empty was the
|
|
352
|
+
asymmetry. A document that cannot be read is skipped rather than failing the whole read — a skill
|
|
353
|
+
should not become unreadable because something beside it is.
|
|
354
|
+
|
|
355
|
+
`${CLAUDE_SKILL_DIR}` resolves to the skill's own directory — the reason skills are directories
|
|
356
|
+
rather than single files. A skill ships scripts and reference documents beside its `SKILL.md`, and
|
|
357
|
+
without the placeholder the body had no expressible path to them: the skill does not know where it
|
|
358
|
+
was installed, and neither does its author at the time of writing.
|
|
359
|
+
|
|
360
|
+
Only for a skill read from disk. An inline skill has no directory, so the text is left exactly as
|
|
361
|
+
written — an honest limit rather than a guess. Leaving it says "this does not apply here"; inventing
|
|
362
|
+
a path would hand the model a command that fails somewhere plausible.
|
|
363
|
+
|
|
364
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook emitting the **documented** deny shape is now honoured instead of being read as `allow`.
|
|
365
|
+
|
|
366
|
+
`hooks-source.ts` advertises a config shape "identical to Claude Code's `settings.json` hooks", so a
|
|
367
|
+
consumer writes the guard that documentation specifies:
|
|
368
|
+
|
|
369
|
+
```json
|
|
370
|
+
{
|
|
371
|
+
"hookSpecificOutput": {
|
|
372
|
+
"hookEventName": "PreToolUse",
|
|
373
|
+
"permissionDecision": "deny",
|
|
374
|
+
"permissionDecisionReason": "…"
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`parseDecisionFromStdout` read only a **top-level** `decision` and accepted only
|
|
380
|
+
`deny` / `feedback` / `allow`. The nested shape has no top-level `decision` at all, so it fell past
|
|
381
|
+
every branch to the final `return { decision: "allow" }`. The JSON parsed, nothing warned, and the
|
|
382
|
+
tool call proceeded.
|
|
383
|
+
|
|
384
|
+
Measured against four inputs before the fix: the documented shape → allow; the deprecated-but-
|
|
385
|
+
documented `{"decision":"block"}` → allow; `Stop` with `block` → allow; only this runtime's own
|
|
386
|
+
`{"decision":"deny"}` denied.
|
|
387
|
+
|
|
388
|
+
Three spellings mean deny and all three are now read: the nested `permissionDecision`, the
|
|
389
|
+
deprecated `block`, and the native `deny`. A nested `ask` projects to deny — collapsing it to allow
|
|
390
|
+
would be the same fail-open one value over, since this runtime has no third state to put the
|
|
391
|
+
question to.
|
|
392
|
+
|
|
393
|
+
**Deliberately unchanged**: an unrecognised shape still resolves to `allow`. Making it deny would
|
|
394
|
+
refuse every hook that prints diagnostics and happens to emit JSON — a behaviour change with its own
|
|
395
|
+
blast radius, and its own measurement. The three documented denials are unambiguous; that case is
|
|
396
|
+
not.
|
|
397
|
+
|
|
398
|
+
The direction is what made this expensive. A missing hook event is discoverable: the user sees
|
|
399
|
+
nothing happen and investigates. A veto that silently does not fire is indistinguishable from a veto
|
|
400
|
+
that fired and approved.
|
|
401
|
+
|
|
402
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook event the runtime does not fire is now reported even when the host installed no diagnostics
|
|
403
|
+
sink.
|
|
404
|
+
|
|
405
|
+
The loader maps four Claude Code event names and skips the rest. Skipping is honest — the runtime
|
|
406
|
+
genuinely does not fire the others — but the notice went through `warnOnce` → `diag`, and `diag` is
|
|
407
|
+
silent by default. So an operator declaring a `PreCompact` guard got nothing: no hook, no message,
|
|
408
|
+
and no way to learn either.
|
|
409
|
+
|
|
410
|
+
`diag`'s silence is right for chatter: a library must not assume the host's stderr is a free-form
|
|
411
|
+
log, because in a TUI it is the render surface. A configuration the operator **wrote** and this
|
|
412
|
+
runtime will not honour is not chatter. `warnFailureOnce` routes it through `diagFailure`, the
|
|
413
|
+
channel that already exists for exactly this and whose docblock records the precedent — `[#189](https://github.com/usetheokit/theokit-sdk/issues/189)`,
|
|
414
|
+
where an MCP server failed to start, the only report went to `diag()`, the embedding UI never read
|
|
415
|
+
it, and "the user saw an agent with missing tools and no reason given".
|
|
416
|
+
|
|
417
|
+
A dropped hook is that shape with a sharper edge, because the missing thing is a guard: the operator
|
|
418
|
+
declared a refusal, it silently does not exist, and nothing distinguishes that from a refusal that
|
|
419
|
+
ran and approved.
|
|
420
|
+
|
|
421
|
+
A sink still takes precedence when one is installed — this only changes what happens when none is.
|
|
422
|
+
|
|
423
|
+
**A note for whoever writes the next test here.** `vitest.setup.ts` installs a stderr-forwarding
|
|
424
|
+
sink for the duration of every test, so the default path is the one shape this suite never
|
|
425
|
+
exercises. A test written the obvious way passes before the fix and proves nothing; the one added
|
|
426
|
+
here removes the sink in `beforeEach` to stand in for a consumer that never called
|
|
427
|
+
`setDiagnosticsSink`.
|
|
428
|
+
|
|
429
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A native hook can locate its own project, and a ported hook script receives the field names it was written against.
|
|
430
|
+
|
|
431
|
+
**The project directory, in both dialects.** `CLAUDE_PROJECT_DIR` was supplied to commands imported
|
|
432
|
+
from Claude Code ([#522](https://github.com/usetheokit/theokit-sdk/issues/522), after a hook written the documented way expanded to a leading `/` and denied
|
|
433
|
+
every turn). The native dialect got `{}`, on the reasoning that a `.theokit/` hook "is written
|
|
434
|
+
against THIS runtime and inherits it already" — true of the runtime's *behaviour*, not of a project
|
|
435
|
+
*path*. Nothing in the inherited environment says where the project is, so a native hook had to
|
|
436
|
+
depend on the process cwd: the exact dependency the foreign fix removed. `THEOKIT_PROJECT_DIR` is
|
|
437
|
+
the native counterpart, under the native spelling, because a ported script reaches for the name its
|
|
438
|
+
own docs use.
|
|
439
|
+
|
|
440
|
+
**The stdin payload.** It carried this runtime's field names only. A script ported from Claude Code
|
|
441
|
+
reads `tool_name`, `tool_input`, `tool_response`, `hook_event_name` and `cwd` — it got `undefined`
|
|
442
|
+
for every one, and **ran**, deciding on nothing while looking like a working guard. That is worse
|
|
443
|
+
than a script that fails: the operator's evidence that the guard works is identical either way.
|
|
444
|
+
|
|
445
|
+
The documented names are added **beside** the existing ones, never instead. Both dialects execute
|
|
446
|
+
through one path, so renaming would break every native script to fix the ported ones.
|
|
447
|
+
|
|
448
|
+
Only what this runtime knows. `session_id`, `transcript_path`, `permission_mode` and `prompt_id`
|
|
449
|
+
stay absent because their values would have to be invented — a script that branches on an invented
|
|
450
|
+
session id branches on a lie. Same trade as `CLAUDE_PLUGIN_ROOT`, which stays unset for the same
|
|
451
|
+
reason one module over.
|
|
452
|
+
|
|
453
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - Skill frontmatter's two authorization fields are read instead of discarded, and one of them is now enforced.
|
|
454
|
+
|
|
455
|
+
`buildFrontmatter` kept exactly `name`, `description`, `category` and `dependencies`. `user-invocable`
|
|
456
|
+
and `disable-model-invocation` survived the YAML parser and were dropped one function later, so a
|
|
457
|
+
template could declare a restriction the runtime could not honour — and the default is disclose-all.
|
|
458
|
+
`create-theokit` ships seven skills carrying `user-invocable: false`, and all seven were inert.
|
|
459
|
+
|
|
460
|
+
**`disable-model-invocation: true` is now enforced.** A skill reaches the model through exactly one
|
|
461
|
+
place — the system-prompt context — so the declaration is a filter there. This is the capability the
|
|
462
|
+
format names when it says you do not want the model deciding to deploy because the code looks ready:
|
|
463
|
+
a side-effecting skill a human may run and the model may not propose. It is a **disclosure** rule,
|
|
464
|
+
not an execution rule — `skills.get(name)` still resolves a hidden skill, because a caller naming one
|
|
465
|
+
has already made the decision the field exists to keep away from the model. Widening it to execution
|
|
466
|
+
would break the case the field is for.
|
|
467
|
+
|
|
468
|
+
**`user-invocable: false` is carried, deliberately not enforced here.** This SDK has no user-facing
|
|
469
|
+
invocation surface for skills; there is no slash command. Reading `agent.skills.list()` as "the user"
|
|
470
|
+
would be a guess — a host may call it to build a picker or to introspect, and those want opposite
|
|
471
|
+
answers. The declaration now travels to the host that knows, instead of being thrown away.
|
|
472
|
+
|
|
473
|
+
A value the dialect cannot read is refused rather than ignored. This dialect coerces only the
|
|
474
|
+
literals `true` and `false`, so `disable-model-invocation: yes` — a valid YAML boolean — arrived as
|
|
475
|
+
the string `"yes"`, compared unequal to `true`, and the skill was disclosed. The author wrote a
|
|
476
|
+
restriction and got the default. A restriction that fails open is worse than an absent one, because
|
|
477
|
+
the author stops looking; the skill is now reported as invalid and excluded, naming the value.
|
|
478
|
+
|
|
479
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`429ddff`](https://github.com/usetheokit/theokit-sdk/commit/429ddff2bcfff7ab750257e6dd13de9dd91eb0a8) Thanks [@usetheodev](https://github.com/usetheodev)! - Fixes a ReDoS in the critical-path floor's destructive-command matcher (CodeQL: polynomial regular
|
|
480
|
+
expression on uncontrolled data).
|
|
481
|
+
|
|
482
|
+
```
|
|
483
|
+
/(?:^|[;&|]\s*)\s*(rm|rmdir|shred|mkfs\S*|dd)\s/
|
|
484
|
+
^^^ ^^^ adjacent quantifiers over overlapping classes
|
|
485
|
+
and an unbounded \S* inside the alternation
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Measured on the inputs CodeQL named: 60 000 spaces after a `;` took **2 094ms**, and 12 000 `&mkfs`
|
|
489
|
+
repetitions took **600ms** — clean quadratic growth. After the rewrite, 0.36ms and 0.33ms.
|
|
490
|
+
|
|
491
|
+
It mattered because of the commit beside it. While nothing called the floor, a slow matcher was a
|
|
492
|
+
latent cost; wiring the floor into `permission-plugin.ts` put it in front of **every tool call**, so
|
|
493
|
+
one crafted argument stalls the agent instead of being refused by it. Making a guard reachable and
|
|
494
|
+
making its cost matter are the same act.
|
|
495
|
+
|
|
496
|
+
The separator no longer consumes whitespace the following `\s*` already consumes, and the `mkfs`
|
|
497
|
+
suffix is a bounded dotted variant (`mkfs.ext4`, `mkfs.xfs`, `mkfs.btrfs`) rather than "any run of
|
|
498
|
+
non-space". Verified identical across thirteen cases spanning every branch.
|
|
499
|
+
|
|
500
|
+
Also documented, because measuring it turned it up: a DEVICE NODE is not a critical path.
|
|
501
|
+
`mkfs.ext4 /dev/sda` and `dd of=/dev/sda` pass this floor — the tier is about the working directory,
|
|
502
|
+
the home directory and the filesystem root, and `/dev/sda` is none of them. A reader who sees `mkfs`
|
|
503
|
+
in the pattern reasonably concludes otherwise, so a test now pins the real behaviour.
|
|
504
|
+
|
|
505
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - The order nested instruction files reach the prompt is pinned, and the one real divergence from the spec is stated.
|
|
506
|
+
|
|
507
|
+
A parity survey reported three defects here. Re-measured by execution, two did not hold:
|
|
508
|
+
|
|
509
|
+
- **`.claude/rules/*.md` is read** — spec `claude-rules`, priority 47. A probe through `runDiscovery`
|
|
510
|
+
over a temp project returned one source carrying the rule. The original zero-file grep was against
|
|
511
|
+
a layer that does not do the reading.
|
|
512
|
+
- **Precedence is not reversed.** `walkUpForFile` returns nearest-first, and that is not what the
|
|
513
|
+
model sees: `applyAggregateCap` re-sorts by priority and then by absolute path, so a three-level
|
|
514
|
+
project measured `walk=[DEEP,SUB,ROOT]` and `prompt=[ROOT,SUB,DEEP]` — the spec's root-down order,
|
|
515
|
+
where the nearer file refines the wider one instead of being buried under it.
|
|
516
|
+
|
|
517
|
+
**The correct behaviour was correct by accident**, which is why this changeset exists. Root-down falls
|
|
518
|
+
out of a tie-break written for prompt-cache determinism (EC-J), and nothing stated it. Change the
|
|
519
|
+
tie-break, or name a subdirectory lexically smaller than its parent, and a repository-wide
|
|
520
|
+
instruction starts being read after the nested one meant to refine it — with nothing to catch it. It
|
|
521
|
+
is now pinned by a test that also mutation-checks the inverse.
|
|
522
|
+
|
|
523
|
+
**The divergence that is real:** the walk stops at the git root, while the spec continues to every
|
|
524
|
+
directory above cwd. Kept, with the reason — a `CLAUDE.md` in a home directory or in `/tmp` would
|
|
525
|
+
silently apply to every repository underneath it, and an instruction file nobody in the project wrote
|
|
526
|
+
is the one case where finding more is worse than finding less. The operator-home question stays a
|
|
527
|
+
deliberate, separate decision rather than a side effect of how far a loop runs.
|
|
528
|
+
|
|
529
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`3ae3766`](https://github.com/usetheokit/theokit-sdk/commit/3ae37660a5a012968115e3e611155cf74397ec23) Thanks [@usetheodev](https://github.com/usetheodev)! - States, next to the write floor that does exist, that there is no read-side equivalent — and pins it
|
|
530
|
+
with a test so the claim and the behaviour cannot drift apart.
|
|
531
|
+
|
|
532
|
+
`permissionFloorReason` refuses a WRITE into `.claude/`, `.theokit/`, `.ssh` or `.gnupg` under every
|
|
533
|
+
allow rule. A READ of the same paths is refused by nothing: reads are governed by the rule language
|
|
534
|
+
and by nothing above it. `Read(path:./.env)` works and deny-before-allow means a deny cannot be
|
|
535
|
+
overtaken — but every such rule has to be written, nothing is refused by default, and a bare `Read`
|
|
536
|
+
allow grants reading any path the process can open.
|
|
537
|
+
|
|
538
|
+
The asymmetry was invisible from the module. `PROTECTED_SEGMENTS` reads like a list of protected
|
|
539
|
+
paths and is only half that: `.ssh` cannot be written through any rule, and can be read through an
|
|
540
|
+
ordinary allow.
|
|
541
|
+
|
|
542
|
+
`additionalDirectories` — the reference's key for an operator to WIDEN what an agent may read — has
|
|
543
|
+
no equivalent, and cannot have one while there is no fence to widen. Measured: zero occurrences in
|
|
544
|
+
this package and zero in `@theokit/agents`.
|
|
545
|
+
|
|
546
|
+
No fence was added, and not because one is unwanted. `permissionFloorReason` sees a tool name and an
|
|
547
|
+
argument map; a fence matching on those alone would miss every read that reaches the filesystem
|
|
548
|
+
another way — a shell command, a plugin, an MCP server. Shell reads are already confined by
|
|
549
|
+
`SandboxMode`, and a second containment vocabulary here would leave two answers to "may this be
|
|
550
|
+
read" that disagree at the edges. Deciding which layer owns that boundary is a measured decision,
|
|
551
|
+
not a docblock.
|
|
552
|
+
|
|
553
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - The hook loader stops claiming parity it does not have, and states the set it does.
|
|
554
|
+
|
|
555
|
+
The module docblock advertised a config shape "identical to Claude Code's `settings.json` hooks". The
|
|
556
|
+
SHAPE is identical; the event COVERAGE is four of the thirty-three documented events. Measured by
|
|
557
|
+
execution — a `hooks.json` declaring all thirty-three, through the shipped loader, yielded
|
|
558
|
+
`PreToolUse`, `PostToolUse`, `UserPromptSubmit` and `Stop`. Thirteen of the sixteen the spec marks
|
|
559
|
+
"Can block? Yes" were among the missing.
|
|
560
|
+
|
|
561
|
+
**The map is deliberately not grown.** Mapping a name the runtime does not fire is strictly worse
|
|
562
|
+
than refusing it: an operator declaring `PreCompact` today gets a report saying it will not fire;
|
|
563
|
+
with the name mapped they would get silence and a guard that never runs — a declared veto that does
|
|
564
|
+
not exist. The map grows when the seam exists, one event at a time.
|
|
565
|
+
|
|
566
|
+
`CLAUDE_CODE_EVENT_MAP` is now exported so the supported set is stated rather than implied, and a
|
|
567
|
+
test derives its expectation from it: adding a seam turns that test red, which is where the docblock
|
|
568
|
+
claim gets updated with it. The same test lists the fourteen unwired blocking events in priority
|
|
569
|
+
order — an unwired veto loses a capability, an unwired observer loses a signal — so the next person
|
|
570
|
+
picking one up does not re-derive which is which.
|
|
571
|
+
|
|
572
|
+
`postRun` has no entry on purpose: it fires per RUN, and no documented Claude Code event means that.
|
|
573
|
+
`SessionEnd` is the near miss, and a session is not a run.
|
|
574
|
+
|
|
575
|
+
- [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - `MEMORY.md` says, at both ends, which of two contracts it is.
|
|
576
|
+
|
|
577
|
+
The Claude Code CLI's is a plain file under its own home, capped at 200 lines / 25 KB on read and
|
|
578
|
+
swept on `cleanupPeriodDays`. This SDK's is the durable-memory subsystem — a SQLite+FTS5 store under
|
|
579
|
+
`.theokit/memory/`, with `memory_search` / `memory_get` tools, no index cap, and a different
|
|
580
|
+
directory entirely. Same filename, different directory, different semantics.
|
|
581
|
+
|
|
582
|
+
**A parity survey reported `CLAUDE_CONFIG_DIR` as absent; it is not.** Measured across both packages:
|
|
583
|
+
it is read in `claudeProjectMemoryDir`, which resolves the CLI's memory directory for interop, keyed
|
|
584
|
+
by git root. The original grep ran only against `@theokit/agents`, where it is genuinely absent, and
|
|
585
|
+
reported it absent everywhere — the third item in this release whose evidence was measured in the
|
|
586
|
+
wrong place or under the wrong name.
|
|
587
|
+
|
|
588
|
+
`CLAUDE_CONFIG_DIR`'s scope is now stated: it names the CLI's home so this reader finds the right
|
|
589
|
+
directory, and it relocates no user-level root of this product's own, because there is none — the
|
|
590
|
+
config roots resolved elsewhere are project-relative. A blank value is treated as unset rather than
|
|
591
|
+
as a root, pinned by a test: `CLAUDE_CONFIG_DIR=""` is what an unset shell variable expands to in a
|
|
592
|
+
wrapper script, and reading it as a root produces `/projects/…`, which exists on no machine and fails
|
|
593
|
+
silently as "the CLI has no memories here".
|
|
594
|
+
|
|
595
|
+
**What remains unimplemented, and why that is a decision.** `autoMemoryEnabled`,
|
|
596
|
+
`autoMemoryDirectory`, `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, `cleanupPeriodDays` and the read cap have
|
|
597
|
+
no counterpart. Interop is one-way on purpose: a memory the CLI recorded stays visible, and this
|
|
598
|
+
runtime does not write into a store another product owns the lifecycle of. A `cleanupPeriodDays`
|
|
599
|
+
implemented here would delete files the CLI expects to find.
|
|
600
|
+
|
|
601
|
+
The filename is kept rather than renamed — one of the two is another product's, and renaming it here
|
|
602
|
+
would break the interop the reader exists for.
|
|
603
|
+
|
|
3
604
|
## 5.5.0
|
|
4
605
|
|
|
5
606
|
### Minor Changes
|