Documentation
¶
Overview ¶
Package policyfile reads and writes kiro-cli's native Cedar permission policy files (permissions.yaml) for the user and workspace scopes.
vibekit is the sole programmatic writer of these files on the acp bridge: KAS only READS them there (its acp consent dialog offers allow_once / reject_once, never a persisted "always", so KAS's own addRuleToFile never runs on the acp path). KAS hot-reloads the file on change via a chokidar watcher, emitting _kiro/policy/changed — so a write here takes effect on every live session with no bridge restart (verified live).
On-disk shape (verified against the KAS 2.12 acp-server bundle + live):
rules:
- capability: fs_write # required; one of the known capabilities
effect: ask # required; allow | deny | ask
match: ["src/**"] # optional glob list
exclude: ["**/secret.txt"] # optional glob list
Load tolerates BOTH block YAML (KAS's / a hand-editing user's format) and JSON (JSON is valid YAML 1.2). We MARSHAL block YAML so files stay human readable and consistent with KAS's own writer.
Path facts (verified live — KAS resolves the base from $HOME via os.homedir(), NOT KIRO_HOME):
user: <home>/.kiro/settings/permissions.yaml
workspace: <home>/.kiro/workspace-roots/<hash>/permissions.yaml
hash = hex(sha256(abs(workDir)))[:16] (KAS computeWorkspaceHash)
POLICY_FILENAMES is ["permissions.yaml", "permissions.json"] and KAS's loadPolicyFromDir returns the FIRST that EXISTS, so we always target the .yaml name to stay authoritative.
Index ¶
- Constants
- Variables
- func Capabilities() []string
- func PathFor(scope string, roots Roots) (string, error)
- func RelaxCapabilities() []string
- func Save(ctx context.Context, path string, f *File) error
- func Signature(r *Rule) string
- func ValidEffect(effect string) bool
- func ValidScope(scope string) bool
- func WorkspaceHash(workDir string) string
- type File
- type Profile
- type Roots
- type Rule
Constants ¶
const ( ScopeUser = "user" ScopeWorkspace = "workspace" )
Scope names vibekit can write. Kiro/administration are read-only baselines; agent comes from the agent profile; session is runtime.
const ( EffectAllow = "allow" EffectDeny = "deny" EffectAsk = "ask" )
Effect values.
const ( PresetReadWorkspace = "read-workspace" PresetReadOnlyShell = "read-only-shell" PresetReadAll = "read-all" PresetEditWorkspace = "edit-workspace" PresetDevShell = "dev-shell" PresetAllowAll = "allow-all" )
Preset ids KAS's registry accepts. Pinned because an unknown id is not a degraded grant but a failed session: validatePresetIds throws InvalidParamsError and `session/new` never completes, so an upstream rename would take every chat down at its first prompt rather than quietly granting less.
const ( ProfileGuarded = "guarded" ProfileReadOnly = "read-only" ProfileTrusted = "trusted" ProfileUnrestricted = "unrestricted" // ProfileCustom sends NO presets, which is what makes the files the whole // policy. Note what that costs and why it is still right: without // read-workspace there is no fs_read floor, so an EMPTY custom policy makes // the agent ask even to read a file. The UI reaches Custom either through the // Customize button, which copies what is currently in force into the user // file, or by direct selection, which copies nothing and leaves whatever the // files already hold. Direct selection does NOT start blank: a selection // removes only the rules the profile mechanism itself could have written // ([File.SetProfileRules]), so a hand-authored rule is still there afterwards. ProfileCustom = "custom" )
Profile ids. vibekit's own vocabulary, persisted as a setting, so free to differ from KAS's preset names — and they do differ, because a profile is a POSTURE while a preset is a rule bundle.
The ladder mirrors KiroCrew's four levels (its picker offers normal / trust_reads / trust / yolo) but deliberately not its NAMES. Each of ours is an adjective describing the agent's standing, which keeps one part of speech across the set and makes the ordering readable: guarded, read-only, trusted, unrestricted. "Normal" named a default rather than a behaviour, "reads" was a verb doing an adjective's job, and "yolo" is a joke in a security control — the honest word for it is what it does, which is remove every restriction vibekit can remove.
Crew time-boxes its loosest level with a duration and a one-time acknowledgement. Ours deliberately does NOT, by decision: it stays on until it is changed, matching the switch it replaces.
const DefaultProfile = ProfileGuarded
DefaultProfile is what an unset or unrecognised setting resolves to. Guarded rather than Custom: it reproduces the floor vibekit ships today, where Custom would silently remove the fs_read floor from an instance that never chose to.
Variables ¶
var ( ErrInvalidScope = errors.New("scope must be user or workspace") ErrInvalidEffect = errors.New("effect must be allow, deny, or ask") ErrCapabilityShape = errors.New("capability must be a non-empty token with no control characters") ErrTooManyRules = errors.New("policy file has too many rules") ErrPatternTooLong = errors.New("match/exclude pattern too long") ErrPatternInvalid = errors.New("match/exclude pattern contains invalid characters") ErrPatternEmpty = errors.New("match/exclude list has no non-empty pattern") )
Errors surfaced to the HTTP edge.
There is deliberately no "unknown capability" error. The capability VOCABULARY is KAS's to define and KAS's to enforce, and duplicating it here bought nothing while costing the ability to write a rule for any capability newer than this file.
How KAS reports one, read off 2.18.0, because the obvious guess is wrong: an unrecognised capability is NOT fatal. validateRule returns `{rule: null, warning: "Skipping rule N in <source>: unknown capability …"}`, so that ONE rule is dropped and the rest of the file still loads — unlike a bad effect, which throws PolicyParseError and fails the whole file (vibekit cannot write that: ValidEffect gates it). Because the entry is `fatal: false`, it does NOT arrive on _kiro/policy/error, which KAS emits only `if (hasFatalErrors)`. It rides _kiro/policy/changed instead, with `status: "success"` and the warning in that notification's `errors` array — which vibekit decodes (translate/policy.go) into the permissions_changed SSE, and the client renders from `payload.errors` in permissions-ui.ts. So the user IS told; the channel is just not the one named "error".
Functions ¶
func Capabilities ¶
func Capabilities() []string
Capabilities returns the suggested capability set, sorted, for the UI picker. It is a starting point, not a permitted set: a caller may write a rule naming a capability that is not in here.
func PathFor ¶
PathFor returns the permissions.yaml path for the given scope.
scope stays a separate parameter: it is the discriminator this switches on, and confusing it with a root is already loud (a path is not "user" or "workspace", so it returns ErrInvalidScope). The silent mistake was the pair.
func RelaxCapabilities ¶ added in v0.4.49
func RelaxCapabilities() []string
RelaxCapabilities returns the capability set the LOOSEST security profile writes broad allow rules for, sorted. It is the broadest grant a permissions file can express.
Its consumer is Profile.FileRules on the unrestricted rung, reached through relaxRules in profile.go — not the Settings -> Permissions relaxation CHECKBOX this doc used to name, which the profile picker replaced. The switch is gone; the set outlived it because the question it answers ("what is the broadest grant") is the same one the loosest rung has to answer.
DERIVED as `all` plus every non-umbrella capability that alias does not cover, which today is exactly {all, sandbox_network}, rather than listed — so it cannot name a capability the vocabulary snapshot does not have, and a change to either input is a visible decision.
TWO rules rather than one, because `all` is an alias over a fixed table and not a wildcard: writing only `all` would leave sandbox_network asking while the rung's own description says it never asks. Two rather than the twelve discrete names for the opposite reason: eleven of them would be pure noise in the Active policy list, and keeping the alias is what makes a capability a later KAS version adds to BUILTIN allowed with no vibekit release, which is what someone who picked this rung meant.
It writes one bare rule per member, which is what makes it exactly reversible: Signature keys on capability + effect + globs, so removing the same bare rules removes precisely what was written and leaves any hand-authored narrower rule for the same capability untouched. File.SetProfileRules is what depends on that.
ONE set, not a ladder of its own. An "everyday" variant that withheld `power` was built and then removed (2026-08-25) on the user's call: two postures whose only difference is one capability cost a reveal rule, a cascade rule and a second status line, and the narrow shape stays expressible by hand as `all: allow` plus `power: ask` — which works cleanly, since deny > ask > allow and `power` has none of shell's parse-driven ask paths. What this grants that a user should know about is therefore stated in the picker's copy rather than withheld by omission: a power runs its author's code at the user's privilege, and Cedar is the only guard, because a power's manifest carries no permissions field.
What it CANNOT do, because effects resolve by restrictiveness and a hardcoded scope sits above every file: the kiro scope still denies writes under ~/.kiro/settings, .kiro/settings and ~/.kiro/workspace-roots, and still asks before writing .git/**, .kiro/agents/**, .kiro/hooks/**, .vscode/** and **/*.code-workspace. Measured on the live engine with an all=allow rule in force. The picker's description says so; copy that implied otherwise would be the same defect as a control that silently does nothing.
func Save ¶
Save writes the file atomically (temp → fsync → rename → dir-fsync) via cplieger/atomicfile, mode 0600 (policy is sensitive), creating parent dirs at 0700. A crash mid-write can never leave a truncated policy file (which would silently drop every rule at the next KAS reload).
func Signature ¶
Signature is the dedup/equality key for a rule: capability + effect + sorted match + sorted exclude. Mirrors KAS ruleSignature so vibekit's notion of "same rule" matches the engine's.
func ValidEffect ¶
ValidEffect reports whether effect is allow/deny/ask.
func ValidScope ¶
ValidScope reports whether scope is writable by vibekit.
func WorkspaceHash ¶
WorkspaceHash mirrors KAS computeWorkspaceHash on Linux: the first 16 hex chars of sha256 over the workspace root. The root is canonicalized here — absolute, lexically cleaned, no "." / ".." segments, no trailing slash — to match the path.resolve output KAS hashes on its side. Canonicalizing at the hash (rather than trusting workDir verbatim) keeps vibekit's workspace-roots/<hash> directory in lockstep with KAS's for any non-canonical KIRO_WORK_DIR — a trailing slash, a "/a/../b" form, or a relative value. A divergent hash would silently write workspace-scope rules to a directory KAS never reads, so the rules would persist yet never be enforced.
Types ¶
type File ¶
type File struct {
Rules []Rule `yaml:"rules"`
}
File is the whole permissions.yaml document.
func Load ¶
Load reads and parses a permissions file. A missing file yields an empty File and nil error (the common "no rules yet" case). Parse failures and oversize files are errors so the caller never silently clobbers a hand-authored file it couldn't understand.
The open is atomicfile.OpenRegular rather than os.ReadFile for two reasons, both measured. A FIFO at the name blocks os.ReadFile in open(2) with no context deadline able to rescue it (still blocked past 2s on go1.27.0), and this file lives under $HOME/.kiro, which the agent's own shell can write — so one mkfifo wedged the whole permissions REST surface permanently. And os.ReadFile sized its buffer from the file and read all of it BEFORE the maxPolicyFileSize check ran on the result, so the 1 MiB bound was enforced after an arbitrarily large file had been pulled into memory; ReadBoundedFile stats the descriptor first. OpenRegular also refuses a symlink at the final component, which matches Save: atomicfile's write entry points already refuse to write through one, so a policy vibekit would not write is now a policy it will not read either.
The path is made absolute first because OpenRegular requires that. os.ReadFile resolved a relative path against the process cwd, and filepath.Abs preserves exactly that, so no caller's meaning changes.
func (*File) Remove ¶
Remove deletes the first rule matching r by Signature. Returns true if a rule was removed.
func (*File) ReplaceEffect ¶ added in v0.2.8
ReplaceEffect changes the effect of the rule matching old (by Signature) IN PLACE, preserving its position in the file — one atomic mutation, so an in-place edit can never half-apply the way a client-side remove+add could. When the resulting rule would duplicate an existing one, the old rule is removed instead (the duplicate already expresses the target state). Returns true if the file changed: false means the old rule is absent or the effect is already effect.
func (*File) SetProfileRules ¶ added in v0.6.3
SetProfileRules makes rules the security profile's whole contribution to this file, preserving every rule the profile mechanism did not write.
It drops every rule whose Signature appears in ProfileOwnedRules and then Upserts each of rules, so a selection replaces the OUTGOING profile's rules and nothing else. Returns ErrTooManyRules when the file is already at the cap. SetProfileRules(nil) is the remove-and-add-nothing case, which is what a restrictive rung and the workspace file both want, and it can never return ErrTooManyRules because nothing is Upserted.
ALL OR NOTHING: an error leaves f exactly as it was found. The removal pass runs before the Upsert loop, so building in place would leave an ErrTooManyRules holding a PARTIAL posture — the outgoing profile's rules gone and only part of the incoming set added — and a caller that Saved after the error would persist it. Staging into a scratch value and assigning once is two lines and removes that; nothing has to know the failure order to use this safely.
MERGE by ownership, and Signature is the only ownership handle there is: a rule carries no provenance in the file, and no RPC reports which writer produced one. The alternative was the blanket overwrite this replaced, which destroyed a hand-authored rule on every profile click without saying so.
Three consequences, all deliberate:
- A hand-authored rule that happens to be byte-identical to one a profile writes — `capability: all, effect: allow` with no globs — is indistinguishable from the profile's own and IS removed. Removal is the right side to err on for that exact shape, because "the profile is the policy" is what the panel promises about it.
- A NARROWER rule for the same capability survives (`all: allow` with a match list is a different Signature), which is the property TestRelaxCapabilities_RulesAreExactlyReversible already pins.
- A surviving deny or ask still beats the profile's allow, because effects resolve by restrictiveness. So a hand-authored rule can outlive a profile change and grant more or less than the profile's name suggests; the panel copy says so, and the Active policy table lists it.
type Profile ¶ added in v0.6.0
type Profile struct {
// ID is the persisted value.
ID string
// Presets are the KAS preset ids sent as _meta.kiro.policyPreset. Empty for
// Custom, which is the difference that makes the files authoritative.
Presets []string
// FileRules are the rules this profile writes to the USER-scope permissions
// file, in ADDITION to sending its presets at the session door — the half that
// reaches a session vibekit did not open.
//
// A preset arrives at SESSION scope bound to the one session it was sent on,
// and KAS creates a workflow step's session itself with no _meta, so a preset
// can never reach one. A user-scope file rule is evaluated for every session in
// the process, step sessions included, which is why the loosest rung carries
// both halves. Empty on every other rung: a durable allow at user scope
// survives a restart and applies to every ACP client sharing this HOME, so
// materialising a restrictive rung's posture would widen it rather than
// describe it.
//
// THE PICKER'S COPY DUPLICATES THIS DISTRIBUTION and the wire does not carry
// it: vibekit.SecurityProfile ships ID and Presets only, so
// profileDescription in static-src/permissions-ui.ts states "the only preset
// profile that also covers workflow steps" from a hand-maintained copy of which
// rung holds these rules. ("Preset" is the narrowing that keeps it true: Custom
// sends no preset, and its own user-scope rules cover a step session too.) A
// rung gaining FileRules must change that copy in the same commit. What catches
// the omission is
// TestProfiles_OnlyTheLoosestRungWritesFileRules, which tables all five rungs
// against a wantRules boolean — editing that table is the moment to re-read the
// client text.
FileRules []Rule
}
Profile is one entry in the picker.
func ProfileFor ¶ added in v0.6.0
ProfileFor returns the profile with the given id, and whether it exists.
Absence is reported in band rather than through a default, because the two callers want opposite things: the settings reader wants to fall back to Normal with a logged reason, and the session door wants to send nothing rather than guess a posture. A silent default would hand one of them the wrong answer.
type Roots ¶ added in v0.5.0
type Roots struct {
// Home is the base KAS resolves both scopes from ($HOME).
Home string
// WorkDir is the bridge's cwd, needed only for the workspace scope.
WorkDir string
}
Roots are the two filesystem roots a permissions.yaml path resolves against.
A struct because PathFor used to take them as `(scope, home, workDir string)` and two of those three were paths: a transposition compiled, resolved, and wrote a security policy file under the wrong root — user-scope rules landing beneath a workspace hash KAS reads for a different workspace, or workspace rules at the user path where they apply everywhere. Nothing detects that; the file is valid YAML at a valid location and KAS loads it. Named fields make the swap unrepresentable, and a runtime guard could not have caught it at all (both values are absolute directories that exist).
type Rule ¶
type Rule struct {
Capability string `yaml:"capability"`
Effect string `yaml:"effect"`
Match []string `yaml:"match,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
}
Rule is one policy rule. Field order (capability, effect, match, exclude) is the canonical KAS order.
YAML TAGS ONLY. This type had json tags too, on the stated grounds that they "keep it decodable from the REST layer" — measured false: nothing in the workspace ever encodes or decodes a Rule or a File as JSON. The REST layer decodes into its own policyRuleBody and maps the fields across by name, so the tags were dead. They also carried a latent surprise: `omitempty` on a slice changes the wire under encoding/json/v2, where a nil slice emits `[]` instead of being omitted, and sanitizePatterns returns nil for the empty case that the relaxation's own rules carry on every one of them. Deleting them removes both.
func ProfileOwnedRules ¶ added in v0.6.3
func ProfileOwnedRules() []Rule
ProfileOwnedRules returns every rule the profile mechanism itself could have written, deduplicated by Signature. It is the set a selection REMOVES from a writable file before writing the incoming rung's own rules.
The UNION over the whole ladder rather than the outgoing rung's set, for two reasons. Switching from the loosest rung to a restrictive one has to genuinely narrow, and a narrowing that left the previous rung's `all: allow` standing would be the worst failure this mechanism could ship — so the removal set cannot depend on correctly identifying which rung is being left, which is a setting that can be absent or stale. And a rung added later must not orphan its rules on disk when a user moves off it; the union is a property of the code instead.
It also reverses the retired workspace-relaxation checkbox for free: that checkbox wrote byte-identical bare rules from the same RelaxCapabilities() set, so the first profile selection is its own migration with no special case.
func SanitizeRule ¶
SanitizeRule validates + normalizes a rule for writing. It trims and de-dups match/exclude, enforces the length/count caps, checks the effect, and checks the capability's SHAPE. It does NOT default the effect — the caller applies the conservative "default to ask" policy so the choice is explicit at the edge.
The split on capability is deliberate. Its VOCABULARY is not checked: an unrecognised name is written through, because KAS's loader is the authority on which capabilities exist and reports the skip on _kiro/policy/changed's `errors` array (see the Errors block above — it is non-fatal, so it does not reach _kiro/policy/error). Its SHAPE is checked, in the same class as the pattern checks below: an empty, oversized or control-character-bearing token is not a capability KAS could ever have, so forwarding one only puts a rule in a security policy file that the user then has to hand-edit out.