Add typed shell approval store schema 3 - #1890
Conversation
| foreach (var segment in normalized[rootLength..].Split( | ||
| '\\', | ||
| StringSplitOptions.RemoveEmptyEntries)) | ||
| { | ||
| if (segment == ".") | ||
| continue; | ||
|
|
||
| if (segment == "..") | ||
| { | ||
| if (segments.Count == 0) | ||
| return null; | ||
|
|
||
| segments.RemoveAt(segments.Count - 1); | ||
| continue; | ||
| } | ||
|
|
||
| segments.Add(segment); | ||
| } |
| foreach (var audience in audiences) | ||
| totalRemoved += store.RemoveAllForTool(audience, canonicalTool); | ||
| { | ||
| var change = store.TryRemoveAllForTool(audience, canonicalTool); | ||
| if (change is ApprovalStoreChangeResult.Unavailable unavailable) | ||
| { | ||
| WriteStoreError(unavailable.Failure, store, writer); | ||
| return 1; | ||
| } | ||
|
|
||
| totalRemoved += ((ApprovalStoreChangeResult.Completed)change).ChangeCount; | ||
| } |
7946956 to
2bdce84
Compare
Aaronontheweb
left a comment
There was a problem hiding this comment.
Inline guide to the main authority boundaries in this pull request.
| /// <summary> | ||
| /// Creates a typed token-prefix shell entry. | ||
| /// </summary> | ||
| public static ApprovalEntry CreateTokenPrefix( |
There was a problem hiding this comment.
This factory creates new shell authority as immutable whole tokens. The separate LegacyExact factory prevents old free-form grants from gaining prefix authority.
| private static readonly HashSet<string> AllowedMembers = | ||
| ["shell", "match", "verbTokens", "verb", "directory", "createdAt"]; | ||
|
|
||
| internal static ApprovalEntry ReadVersion3(JsonElement element) |
There was a problem hiding this comment.
This reader validates one complete version 3 entry shape. A mixed or malformed entry makes the store unavailable instead of salvaging nearby authority.
| if (context is not null && | ||
| string.Equals(toolName, context.ShellToolName, StringComparison.Ordinal)) | ||
| { | ||
| return ApprovalEntry.CreateLegacyExact( |
There was a problem hiding this comment.
Version 2 shell entries become LegacyExact. Migration preserves their prior exact authority and never turns them into token-prefix grants.
| sourceBytes, | ||
| contents); | ||
| LastMigrationOmittedEntryCount = omittedEntries; | ||
| UpdateCache(data, Encoding.UTF8.GetBytes(contents)); |
There was a problem hiding this comment.
The store publishes its cache only after the version 3 replacement succeeds. A failed write cannot create authority that exists only in memory.
| return false; | ||
| } | ||
|
|
||
| for (var index = 0; index < entry.VerbTokens.Count; index++) |
There was a problem hiding this comment.
Token-prefix comparison uses whole parser tokens and the selected shell case rule. It does not use a raw string prefix.
| return; | ||
| } | ||
|
|
||
| var change = _persistentStore.TryAddApprovals( |
There was a problem hiding this comment.
The actor writes all structured grants as one store operation. A partial persistent batch cannot survive a later entry failure.
| writer.WriteLine("the persisted tool-approvals.json directly."); | ||
| return 1; | ||
| var shell = opts.Shell ?? NativeShell; | ||
| if (!ShellApprovalPhraseParser.TryCreateTokenPrefix( |
There was a problem hiding this comment.
For shell tools, trust-verb asks ShellSyntaxTree for the canonical phrase. Extra operands, redirects, or shell effects cannot silently become broader reusable authority.
| || finite.Values.Distinct(StringComparer.Ordinal).Count() != finite.Values.Count, | ||
| // ShellSyntaxTree proves these domains are bounded. They remain | ||
| // data only and cannot establish path or execution authority. | ||
| ShellValueDomain.IntegerRange => argument.Argument.IsPath, |
There was a problem hiding this comment.
ShellSyntaxTree 0.3.1 proves these scalar domains are bounded. Netclaw accepts them only as non-path data; paths, redirects, identities, and command payloads remain strict.
|
TL;DR; the code for interpreting ShellSytnaxTree output was getting gross, so we formalized the policy layer into its own type system in order to make it easier to enforce and reason about. |
Aaronontheweb
left a comment
There was a problem hiding this comment.
Not quite done reviewing but I have some immediate questions that will inform my next round
| { | ||
| public static IReadOnlyList<ToolApprovalGrant> BuildGrants( | ||
| IReadOnlyList<ApprovalCandidate> candidates, | ||
| bool persistent, |
There was a problem hiding this comment.
should make these value objects to prevent primitive confusion
There was a problem hiding this comment.
Fixed in . now receives one . The context carries the reusable decision, working directory, and session directory. Its factory rejects , , and , so callers cannot swap Boolean flags or create a reusable grant from a non-reusable decision.
| InvalidData = 1, | ||
|
|
||
| /// <summary>The file used a schema newer than this process supports.</summary> | ||
| UnsupportedVersion = 2, |
There was a problem hiding this comment.
Do we have a dedicated schema file for approvals?
There was a problem hiding this comment.
No dedicated JSON Schema file exists today. and are the executable schema. They reject unknown members, mixed entry forms, duplicate keys, bad enums, invalid paths, and spoof characters before any authority becomes available. A separate JSON Schema could help editors, but it would duplicate the security contract unless tests generated or compared it against the executable reader. I left that out of this PR.
| { | ||
| var powerShell7 = ParsePowerShell(source, PwshDialect.PowerShell7); | ||
| var windowsPowerShell = ParsePowerShell(source, PwshDialect.WindowsPowerShell51); | ||
| if (!TryCreateTokenPrefix( |
There was a problem hiding this comment.
attempt both powershells - take the results of the newer one if it's available.
There was a problem hiding this comment.
Fixed in . An abstract PowerShell grant now tries both dialects. It uses a valid PowerShell 7 result first and uses Windows PowerShell 5.1 only when the preferred parse fails. The resolved-environment overload still uses only the selected installed host dialect. Tests cover the PowerShell 7 preference with and the 5.1 fallback with .
| } | ||
| } | ||
|
|
||
| private static bool TryCreateTokenPrefix( |
There was a problem hiding this comment.
not sure why this method needs to exist - couldn't we just jump straight to TryCreateTokenPrefixCore ?
There was a problem hiding this comment.
Agreed. The private wrapper duplicated exception handling that the two public entry points already provide. removes it and calls directly.
| parsed.Syntax.Statements.Count != 1 || | ||
| parsed.Syntax.Statements[0] is not SimpleCommandSyntax simple) | ||
| { | ||
| error = "The shell phrase must contain one complete static command."; |
There was a problem hiding this comment.
I'll need to find this upstream, but who does the parsing into multiple commands if they're strung together? Or is that not the shape being extracted by the ParsedCommand output here?
There was a problem hiding this comment.
does contain every executable occurrence from a complete shell program. This type deliberately rejects that shape because it creates one stored grant from one operator phrase. Runtime commands use , which iterates every . I renamed this type to and clarified that boundary in its public summary.
| clause.Elements.Any(static element => | ||
| element.Role != ClauseElementRole.Verb || element.IsFlag)) | ||
| { | ||
| error = "The shell phrase must have no argument, flag, assignment, redirect, or control effect."; |
There was a problem hiding this comment.
the idea being that you can't have any of these positionals BEFORE the first command? Are there any valid counter-examples?
There was a problem hiding this comment.
Valid shell commands can have an assignment or redirect before the executable, such as or . This grant parser rejects them intentionally. must store exactly the phrase the operator entered, with no extra shell effect. Runtime command analysis still supports those legal forms through .
| var canonicalSource = string.Join(" ", tokens); | ||
| if (!string.Equals(source, canonicalSource, StringComparison.Ordinal)) | ||
| { | ||
| error = $"The shell phrase must equal its canonical form: {canonicalSource}"; |
There was a problem hiding this comment.
are we sure this holds up under all valid / legal conditions?
There was a problem hiding this comment.
No. This equality is intentionally stricter than the complete shell language. Legal quotes, escapes, aliases, extra whitespace, arguments, redirects, and assignments can fail here. The CLI accepts only one canonical phrase because a silent reduction could create broader authority than the operator entered. The renamed type and summary now state this narrow contract.
| return false; | ||
| } | ||
|
|
||
| entry = ApprovalEntry.CreateTokenPrefix(shell, tokens); |
There was a problem hiding this comment.
again, where does this get called n times for each valid command in a command string? This looks like a per-verb function
There was a problem hiding this comment.
It is not called once per runtime command. It creates one stored grant for Usage: netclaw approvals trust-verb [--audience personal|team|public] [--tool ]
Adds a global-wildcard '(verb, null)' approval entry — the verb runs in any cwd
without prompting. Used to pre-approve verbs for unattended/scheduled tasks. and reconstructs one legacy compatibility pattern. Runtime analysis calls , which iterates once per occurrence. The old name hid this difference, so renames it to .
| argument.Argument.Kind != ArgKind.Glob | ||
| && (Environment.Grammar != ShellGrammar.Bash | ||
| || argument.Argument.Kind != ArgKind.EnvVar), | ||
| ShellValueDomain.Unknown => argument.Argument.Kind != ArgKind.Glob, |
There was a problem hiding this comment.
we tolerate globs as the only "unknown" type of domain
There was a problem hiding this comment.
Yes. Raw authored globs are the only value domain accepted here. Netclaw does not trust that unknown value directly. The parser-owned glob source must pass the separate covering-directory and descendant-glob checks. Every other value remains complex.
Aaronontheweb
left a comment
There was a problem hiding this comment.
Left some more comments - still going through this
| /// <summary> | ||
| /// Creates an exact non-shell approval entry. | ||
| /// </summary> | ||
| public static ApprovalEntry CreateNonShell( |
There was a problem hiding this comment.
Non-shell is for things like MCP servers or file access, which can require approval too
| { | ||
| Shell = shell, | ||
| Match = ApprovalMatchKind.TokenPrefix, | ||
| VerbTokens = Array.AsReadOnly(verbTokens.ToArray()), |
There was a problem hiding this comment.
keep the original verbTokens and only string.Join it once IMHO.
| public static ApprovalEntry CreateLegacyExact( | ||
| ApprovalShell shell, | ||
| string verb, | ||
| string? directory = null, |
There was a problem hiding this comment.
some commands have global approval, so not tied to a specific directory
b288541 to
1d1f8a6
Compare
| foreach (var candidate in _armed) | ||
| { | ||
| if (!HasSameExecutionSemantics(candidate.Call, call) | ||
| || !_consumed.TryAdd(candidate, 0)) | ||
| continue; | ||
|
|
||
| key = candidate; | ||
| return true; | ||
| } |
| foreach (var candidate in candidates) | ||
| { | ||
| if (candidate.Directory is null) | ||
| continue; | ||
|
|
||
| if (!IsSafeTemporaryPath(candidate.Directory)) | ||
| return false; | ||
| } |
| foreach (var argument in command.Clause.Args) | ||
| { | ||
| if (!argument.IsPath && !argument.IsCwdAttribution) | ||
| continue; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(argument.Resolved) | ||
| || !IsSafeTemporaryPath(argument.Resolved)) | ||
| { | ||
| return false; | ||
| } | ||
| } |
| foreach (var candidate in candidates) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(candidate.Verb) || !_safeVerbs.Contains(candidate.Verb)) | ||
| return false; | ||
| } |
| foreach (var segment in path.Split(separator, StringSplitOptions.RemoveEmptyEntries)) | ||
| { | ||
| if (segment == ".") | ||
| continue; | ||
|
|
||
| if (segment == "..") | ||
| { | ||
| if (segments.Count == 0) | ||
| return string.Empty; | ||
|
|
||
| segments.RemoveAt(segments.Count - 1); | ||
| continue; | ||
| } | ||
|
|
||
| segments.Add(segment); | ||
| } |
| foreach (var redirect in command.Redirects) | ||
| { | ||
| var safe = redirect switch | ||
| { | ||
| FileRedirectAnalysis file => HasSafeRedirectTarget(file.Target), | ||
| DescriptorDuplicateRedirectAnalysis => true, | ||
| DescriptorMoveRedirectAnalysis => true, | ||
| DescriptorCloseRedirectAnalysis => true, | ||
| HereDocumentRedirectAnalysis => true, | ||
| HereStringRedirectAnalysis => true, | ||
| _ => false | ||
| }; | ||
| if (!safe) | ||
| { | ||
| return false; | ||
| } | ||
| } |
| foreach (var candidate in candidates) | ||
| { | ||
| var effectiveDirectory = candidate.Directory ?? fullCwd; | ||
| if (string.IsNullOrWhiteSpace(effectiveDirectory)) | ||
| return false; | ||
|
|
||
| string fullDirectory; | ||
| try | ||
| { | ||
| fullDirectory = Path.GetFullPath(effectiveDirectory); | ||
| } | ||
| catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| if (!PathUtility.IsWithinRoot(fullDirectory, fullCwd) | ||
| || PathUtility.ContainsSymlinkSegment(fullCwd, fullDirectory)) | ||
| { | ||
| return false; | ||
| } | ||
| } |
1d1f8a6 to
f83b3b7
Compare
* Sync delta specs for the last two completed changes Apply the redirect-shared-temp-to-session-scratch delta to session-cwd and tool-approval-gates. The OpenSpec CLI applied both files. Merge the surface-reminder-schedule-failures delta into netclaw-scheduling by hand. PR #2024 already corrected that requirement against the merged code, so a programmatic apply would undo those corrections. Keep the corrected text and add the scheduling-failure content: - An execution failure and a scheduling failure share one ConsecutiveFailures count. - A successful reschedule alone does not reset the count. - An unattended reschedule site emits ReminderScheduleFailed at Warning severity, and ReminderAutoDisabled at Critical severity on the threshold. - The set_reminder create path and update path return the error to the caller and emit no alert. Drop three stale delta claims. The merged code disables a reminder. It does not set a "paused" status and it does not write tasks.json. ReminderExecutionActor has no ExecutionTimeoutSeconds constant, so the one-hour absolute limit stands. * Archive the last two completed OpenSpec changes Move each change to openspec/changes/archive/2026-08-19-<name>/. The code of both changes is merged on dev. - surface-reminder-schedule-failures: PR #1886. - redirect-shared-temp-to-session-scratch: PRs #1890, #1925, #1926, #1945, and #1956. Tick task 4.3 of redirect-shared-temp-to-session-scratch. The full pr_validation suite ran green on Windows against current dev. Leave the other boxes unticked. The maintainer accepts tasks 4.5 and 4.6 of surface-reminder-schedule-failures as guaranteed by construction. The maintainer accepts task 6.4 of that change and task 4.4 of redirect-shared-temp-to-session-scratch as an eval-credentials gap.
Summary
Security boundary
Validation